Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/reshare-needs-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@haverstack/core': minor
---

Authority is gated the same way whenever it is written.

- Resharing needs read on the Record: `editAccess()`, `setAccess()` and `setUnlisted()` refuse a creator who cannot read their Record, such as one removed from the roster that conveyed it, or a drop-box contributor.
- `permissions` and `unlisted` at `create()` pass the same gate, asked of the Record the create would make.
- A roster entry no longer counts on the principal's side of a delegated request, so a delegated app neither manages a `_group` nor creates one, even with its own DID on the roster. The owner's own software acting for a subject still can.
28 changes: 16 additions & 12 deletions docs/spec/access-control.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions docs/spec/identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,17 @@ This gives roles for free via association labels, and membership is queryable li
- **`member`** — counted by group ACLs (a permission element whose grantee is `{ kind: 'group', groupId, role: 'member' }`).
- **`admin`** — everything `member` gets, plus may manage the Group Record itself: update its content, add or remove roster (`member`/`admin`) associations, and delete it. An `admin` association implies `member` for every purpose, which is why `role: 'member'` on a grantee is the wider set and `role: 'admin'` the narrower.

**Removal from a roster withdraws what the roster conveyed**, a creator's standing to reshare what they wrote included, since that [needs read](./access-control.md#write-implies-read).

**A roster entry and a permission element are the same shape of edge.** Both are elements on the Record they govern, journalled and delta'd identically — see [Access control § Record-level permissions](./access-control.md#record-level-permissions). What separates them is the aspect: a roster is the Group's associations, moving through `editAssociations()`/`setAssociations()` under the Group's admin rule, and an ACL is its permissions, moving through `editAccess()`/`setAccess()` under the reshare rule.

**Role-gated group management.** Mutating a `_group` Record — `patchContent`, `setParent`, `setUnlisted`, `editAssociations`/`setAssociations` of roster associations, `editAccess`/`setAccess`, `delete`, `undelete` — requires the requester to be an `admin` of that Group, or the stack owner. This replaces the ordinary write-bit/grant check for `_group` Records specifically: membership rosters live on the very Record that write access would otherwise let a write-holder rewrite, so "can write this record" is deliberately not sufficient to add or remove members. `ScopedStack` enforces this — _who_ may write a `_group`; the invariant below is a separate question about _what_ a write may leave behind, and `Stack` enforces that for every requester including the owner. Under [delegation](./access-control.md#delegation-principal-and-subject) the rule is asked of both identities: the principal and the subject must each be an `admin` or the owner, so neither a contained app nor the owner's own software seizes a Group on the other's behalf.
**Role-gated group management.** Mutating a `_group` Record — `patchContent`, `setParent`, `setUnlisted`, `editAssociations`/`setAssociations` of roster associations, `editAccess`/`setAccess`, `delete`, `undelete` — requires the requester to be an `admin` of that Group, or the stack owner. This replaces the ordinary write-bit/grant check for `_group` Records specifically: membership rosters live on the very Record that write access would otherwise let a write-holder rewrite, so "can write this record" is deliberately not sufficient to add or remove members. `ScopedStack` enforces this — _who_ may write a `_group`; the invariant below is a separate question about _what_ a write may leave behind, and `Stack` enforces that for every requester including the owner. Under [delegation](./access-control.md#delegation-principal-and-subject) the subject must be an `admin` or the owner, and the principal must be the owner: a roster entry is roster-derived authority, which [never counts on the principal's side](./access-control.md#who-a-grant-reaches). So a contained app manages no Group, whatever roster it is on, and the owner's own software manages one only for a subject who could.

**At least one `admin`, always.** A `_group` Record's roster carries at least one `admin` entry, and that is an invariant of the Record rather than a convenience at create time — no Group is ever management-orphaned. The stack owner can always manage any Group regardless of roster, per the owner's unconditional-access rule.

Three rules hold it, one per way a roster can change:

- **Create** stamps the creator as the first `admin` (a `relationship` association added at create time), so the invariant is true from the Record's first version. A create is otherwise gated as an ordinary create — a `create` grant on `_group@1`, or the owner — and the roster it names is not admin-gated, because there is no prior roster for the rule to protect: a Group reaches nobody until something points a permission or a grant at it, and both are the owner's to write. The creator stamped is the **subject**, so a [delegated](./access-control.md#delegation-principal-and-subject) app creating a Group for someone manages it afterwards only if it named its own DID `admin` in that same create — the management rule asks both identities, and a `create` grant is not standing on the roster.
- **Create** stamps the creator — the **subject** — as the first `admin` (a `relationship` association added at create time), so the invariant is true from the Record's first version. A create needs a `create` grant on `_group@1`, or the owner, and because it writes a roster it is also asked the management rule above, of the Group it would make. Undelegated that always holds, since the creator is its stamped admin; under delegation it refuses an app principal, so no app creates a Group it could not then manage.
- **Any write that would leave the roster with no `admin`** is refused with `StackConflictError` — whether reached through `setAssociations()`, which replaces the roster wholesale, or through `editAssociations()`, which removes entries. The check reads the roster the write would _produce_, not the one it started from, which is what makes it precise: an `admin` may remove themselves while another remains, and may not remove the last one, without either case needing to name who is being removed.
- **Restore** never rolls the roster back at all — see [Restore semantics](./versioning.md#restore-semantics). A Group's content rolls back; its roster is authority and changes only deliberately. So no restore can empty a roster, and none has to be refused to keep from it.

Expand Down
20 changes: 18 additions & 2 deletions packages/core/src/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import { SYSTEM_TYPES } from './types/index.js';
import type {
Actor,
Association,
AuthorityAssociation,
DataAssociation,
Expand All @@ -25,6 +26,7 @@ import type {
RecordId,
RelationshipAssociation,
StackRecord,
TypeId,
} from './types/index.js';
import { associationKey } from './associations/identity.js';
import { baseIdOf } from './schema.js';
Expand Down Expand Up @@ -164,8 +166,22 @@ function isGroupAdminAssociation(
);
}

/** Bootstraps a `_group` record's first admin at create time. */
export function stampGroupAdmin(
/**
* The associations a create stores: as given, plus a `_group`'s creator —
* the owner when none is named — stamped as its first admin.
* See docs/spec/identity.md § Group.
*/
export function createAssociations(
typeId: TypeId,
associations: DataAssociation[] | undefined,
createdBy: Actor | undefined,
ownerEntityId: EntityId,
): DataAssociation[] | undefined {
if (baseIdOf(typeId) !== SYSTEM_TYPES.GROUP) return associations;
return stampGroupAdmin(associations, createdBy?.subjectId ?? ownerEntityId);
}

function stampGroupAdmin(
associations: DataAssociation[] | undefined,
creator: EntityId,
): DataAssociation[] {
Expand Down
61 changes: 39 additions & 22 deletions packages/core/src/scoped-stack/authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ import type {
} from '../types/index.js';
import type { Stack } from '../stack/stack.js';

/** What a fence or reshare decision reads: reach, plus a `_group`'s roster. */
export type GatedRecord = ReachedRecord & Pick<StackRecord, 'associations'>;

/**
* How each side of a request reads a grant. The subject is the entity a
* grant is written about, so every tier counts; on the principal's side
Expand Down Expand Up @@ -275,7 +278,7 @@ export class ScopeAuthority {
return inReach(record, within ? { within } : {});
}

canRead(record: StackRecord, snapshot: AuthoritySnapshot): Promise<boolean> {
canRead(record: ReachedRecord, snapshot: AuthoritySnapshot): Promise<boolean> {
return this.may('read', record, snapshot);
}

Expand All @@ -302,16 +305,38 @@ export class ScopeAuthority {
/**
* `_group` records are managed, not merely written: only the owner or an
* `admin` roster holder may mutate them — ordinary write permissions and
* grants don't apply. Asked of both identities under delegation, like
* a reshare. See docs/spec/identity.md § Group.
* grants don't apply. Under delegation the subject must manage the Group
* and the principal must be the owner: a roster entry, like a group
* grant, never counts on the principal's side.
* See docs/spec/access-control.md § Delegation: principal and subject.
*/
isGroupManager(record: StackRecord): boolean {
if (!this.managesGroup(this.principalId, record)) return false;
return !this.delegated || this.managesGroup(this.subjectId, record);
isGroupManager(record: Pick<StackRecord, 'associations'>): boolean {
if (!this.delegated) return this.managesGroup(this.principalId, record);
return this.principalUnbounded && this.managesGroup(this.subjectId, record);
}

/**
* Whether this request passes `record`'s family fence: none for an
* ordinary family, the owner acting alone for `_grant` and `_install`, the
* Group's managers for `_group`. Asked of the Record a create would make
* as of a stored one. See docs/spec/access-control.md § Per-verb gates.
*/
passesWriterFence(record: GatedRecord): boolean {
switch (familyPolicy(record.typeId).writers) {
case 'owner-alone':
return this.ownerActingAlone;
case 'group-managers':
return this.isGroupManager(record);
case 'ordinary':
return true;
}
}

/** Whether one identity, on its own, manages `record` — see isGroupManager(). */
private managesGroup(entityId: EntityId | null, record: StackRecord): boolean {
private managesGroup(
entityId: EntityId | null,
record: Pick<StackRecord, 'associations'>,
): boolean {
if (!entityId) return false;
if (entityId === this.stack.ownerEntityId) return true;
return groupRoleFromAssociations(record.associations, entityId) === 'admin';
Expand Down Expand Up @@ -448,38 +473,30 @@ export class ScopeAuthority {
return type ? fileRefFields(type.schema) : [];
}

/**
* Whether this request may widen who reaches a record, at create time or
* after. A delegated app may not, unless the owner is its principal:
* widening access is what containment most needs to hold.
* See docs/spec/access-control.md § Delegation: principal and subject.
*/
mayGrantAccess(): boolean {
return this.principalUnbounded;
}

/**
* Whether one identity, on its own, may decide who else reaches `record`
* — the owner-or-creator rule a reshare enforces, asked of one
* side at a time. See
* docs/spec/access-control.md § Delegation: principal and subject.
*/
private mayReshare(entityId: EntityId | null, record: StackRecord): boolean {
private mayReshare(entityId: EntityId | null, record: ReachedRecord): boolean {
if (!entityId) return false;
return entityId === this.stack.ownerEntityId || entityId === record.createdBy?.subjectId;
}

/**
* Whether this request may decide who else reaches `record`: management
* for a `_group`, so a demoted creator keeps no side door; otherwise the
* owner-or-author rule, asked of both sides of a delegation.
* See docs/spec/access-control.md § Record-level permissions.
* owner-or-author rule, asked of both sides of a delegation, of a record
* the request can read. Asked of a create's Record before it exists too.
* See docs/spec/access-control.md § Write implies read.
*/
canReshare(record: StackRecord): boolean {
async canReshare(record: GatedRecord, snapshot: AuthoritySnapshot): Promise<boolean> {
if (familyPolicy(record.typeId).writers === 'group-managers') {
return this.isGroupManager(record);
}
if (!this.mayReshare(this.principalId, record)) return false;
return !this.delegated || this.mayReshare(this.subjectId, record);
if (this.delegated && !this.mayReshare(this.subjectId, record)) return false;
return this.canRead(record, snapshot);
}
}
96 changes: 61 additions & 35 deletions packages/core/src/scoped-stack/scoped-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
*/

import { baseIdOf } from '../schema.js';
import { createAssociations } from '../access.js';
import { validatePatchValues } from '../validate.js';
import { ChangeEmitter, assertSinceUsable } from '../stack/changes.js';
import { SYSTEM_TYPES } from '../types/index.js';
Expand Down Expand Up @@ -275,17 +276,11 @@ export class ScopedStack implements StackClient {
const { writers } = familyPolicy(record.typeId);
// The owner-alone fence is on writes only: reading how a grant came to
// be is not the escalation it exists to stop.
if (fenceGrantRecord) await this.requireOwnerAloneWriter(record, snapshot);
if (writers === 'group-managers') {
if (!this.#authority.isGroupManager(record)) {
throw await this.#authority.denialFor(
record,
`Cannot change group "${id}": only its admins can manage it`,
snapshot,
);
}
return;
if (fenceGrantRecord || writers !== 'owner-alone') {
await this.requireFamilyWriter(record, snapshot);
}
// Management replaces the write bit and grants for a Group.
if (writers === 'group-managers') return;
if (await this.#authority.may(verb, record, snapshot)) return;
const what = `Cannot ${verb} "${id}" (${record.typeId})`;
throw await this.#authority.denialFor(
Expand Down Expand Up @@ -382,9 +377,9 @@ export class ScopedStack implements StackClient {
if (!principal) throw new StackPermissionError('Anonymous requesters cannot create records');
// A grantee is exactly the untrusted actor the `id` skew check below
// exists to stop from forging a sort position, and a delegated app
// acting for the owner inherits none of the owner's extra trust — same
// reasoning as ScopeAuthority.mayGrantAccess(). Asked value-wise, since an
// `undefined` carries no date and Stack.create() reads it as absent.
// acting for the owner inherits none of the owner's extra trust. Asked
// value-wise, since an `undefined` carries no date and Stack.create()
// reads it as absent.
if (
(opts.createdAt !== undefined || opts.updatedAt !== undefined) &&
!this.#authority.ownerActingAlone
Expand All @@ -411,16 +406,7 @@ export class ScopedStack implements StackClient {
);
}
}
if (opts.permissions?.length && !this.#authority.mayGrantAccess()) {
throw new StackPermissionError(
'A delegated principal cannot set permissions, at create time or after',
);
}
if (opts.unlisted && !this.#authority.mayGrantAccess()) {
throw new StackPermissionError(
'A delegated principal cannot create an unlisted record, at create time or after',
);
}
await this.requireAuthorityAtCreate(typeId, opts, snapshot);
this.requireOwnerFields(typeId, undefined, content);
await this.requireAppIdMatchesPrincipal(opts.appId);
if (opts.id !== undefined) {
Expand Down Expand Up @@ -681,10 +667,41 @@ export class ScopedStack implements StackClient {
return record;
}

/**
* Authority a create writes — a Group's roster, the Record's permissions
* and listing — gated as a later write of it would be, asked of the
* Record the create would make. That Record carries none of the authority
* being written, so no call can satisfy its own gate.
* See docs/spec/access-control.md § Write implies read.
*/
private async requireAuthorityAtCreate(
typeId: TypeId,
opts: Pick<BackdatableCreateRecordOptions, 'permissions' | 'unlisted'>,
snapshot: AuthoritySnapshot,
): Promise<void> {
const createdBy = this.#authority.requester;
const record = {
typeId,
createdBy,
associations: createAssociations(typeId, undefined, createdBy, this.stack.ownerEntityId),
};
if (!this.#authority.passesWriterFence(record)) {
throw new StackPermissionError(writerFenceRefusal(typeId));
}
if (
(opts.permissions?.length || opts.unlisted) &&
!(await this.#authority.canReshare(record, snapshot))
) {
throw new StackPermissionError(
`Cannot set permissions or listing on a new ${typeId} record: only its author or the stack owner can decide who else reaches it`,
);
}
}

/** requireReshareable()'s gate, asked of a record already found in its family. */
private async gateReshare(record: StackRecord, snapshot: AuthoritySnapshot): Promise<void> {
await this.requireOwnerAloneWriter(record, snapshot);
if (!this.#authority.canReshare(record)) {
await this.requireFamilyWriter(record, snapshot);
if (!(await this.#authority.canReshare(record, snapshot))) {
throw await this.#authority.denialFor(
record,
`Cannot change permissions on "${record.id}": only its author or the stack owner can reshare it`,
Expand Down Expand Up @@ -726,20 +743,18 @@ export class ScopedStack implements StackClient {
}

/**
* Refuse anyone but the owner acting alone a write to a family only the
* owner writes — `_grant` and `_install`, which are authority — with the
* refusal an unreadable Record answers with. Any other family passes.
* See docs/spec/access-control.md § What a grant covers.
* Refuse a write its family fences off — see
* ScopeAuthority.passesWriterFence() — with the refusal an unreadable
* Record answers with.
*/
private async requireOwnerAloneWriter(
private async requireFamilyWriter(
record: StackRecord,
snapshot: AuthoritySnapshot,
): Promise<void> {
if (familyPolicy(record.typeId).writers !== 'owner-alone') return;
if (this.#authority.ownerActingAlone) return;
if (this.#authority.passesWriterFence(record)) return;
throw await this.#authority.denialFor(
record,
`Only the stack owner may write a ${baseIdOf(record.typeId)} record`,
writerFenceRefusal(record.typeId, record.id),
snapshot,
);
}
Expand Down Expand Up @@ -805,9 +820,12 @@ export class ScopedStack implements StackClient {
* See docs/spec/journal.md § Reading it.
*/
async getJournal(id: RecordId, query: JournalQuery = {}): Promise<RecordJournalEntry[]> {
const record = await this.requireUpdatable(id, { mutating: false }, this.#authority.snapshot());
const snapshot = this.#authority.snapshot();
const record = await this.requireUpdatable(id, { mutating: false }, snapshot);
const entries = await this.stack.getJournal(id, query);
return this.#authority.canReshare(record) ? entries : entries.map(withoutAuthorityChanges);
return (await this.#authority.canReshare(record, snapshot))
? entries
: entries.map(withoutAuthorityChanges);
}

/** See getVersions() — the same mutate-surface gate. */
Expand Down Expand Up @@ -991,3 +1009,11 @@ export class ScopedStack implements StackClient {
);
}
}

/** Why a family fence refused a write: to `id`, or to a new Record when absent. */
function writerFenceRefusal(typeId: TypeId, id?: RecordId): string {
if (familyPolicy(typeId).writers === 'group-managers') {
return `Cannot ${id ? `change group "${id}"` : 'create this group'}: only its admins can manage it`;
}
return `Only the stack owner may write a ${baseIdOf(typeId)} record`;
}
Loading
Loading