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
13 changes: 13 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,8 @@ type Association =

**Note:** `parentId` is a separate native field (not an Association) because hierarchical containment is fundamental enough to warrant indexing at the library level. Associations are for metadata and cross-references.

**Reference creation is gated on `ScopedStack`:** an `attachment` association or file-ref content field requires file access, and a `relationship` association or `parentId` requires read access to the target — see Reference-creation gating under [Permissions](#permissions) for the full rule and its `_group`-roster carve-out. Plain `Stack` is unscoped and does not apply this.

---

## Permissions
Expand Down Expand Up @@ -430,6 +432,17 @@ The core library ships a permission-enforcing wrapper so server implementations

**`ScopedStack.create()`** additionally checks `_grant` records for a `'create'` action on the target type before allowing the Record to be written. Anonymous requesters are always denied. The owner always passes. The created Record's `entityId` is always set to the requester, so `-own` grants apply to it immediately.

**Reference-creation gating.** A `create` grant on a type authorizes writing Records of that type — it does not, by itself, authorize referencing arbitrary other Records or files through that Record. `ScopedStack.create()` and `ScopedStack.associate()` both additionally check that the requester may create the specific reference being written, since a reference elsewhere confers access (an `attachment` association or file-ref content field makes the referenced file downloadable via `getAttachment`; see [Attachment](#attachment)):

- **`attachment` associations and file-ref content fields** require file access: the requester is the owner, uploaded the file themselves (holds an `_attachment@1` Record for it), or can already read some Record referencing it. This is exactly `getAttachment()`'s own access rule — reference creation requires what reference possession would grant.
- **`relationship` associations and `parentId`** require read access to the target Record.
- **`tag` associations** carry no reference and are never gated.
- **`_group` roster associations are exempt** from the `relationship` check above — a roster association's `recordId` names an Entity, not a readable Record (see [Group](#group)), and roster mutation is already gated by the stricter admin-or-owner rule there.

A missing target and an existing-but-inaccessible one **always produce the same `StackPermissionError`**, with no distinguishing detail — otherwise the check itself becomes a confirmation oracle (e.g. for a guessed file hash: content-addressed `fileId`s mean a successful attach-then-read round-trip would otherwise confirm the stack holds those exact bytes). On `update()`, only file-ref fields actually present in the patch are checked — untouched fields carry no new reference.

`appId` and `permissions` are deliberately **not** gated by this: `appId` is self-reported, untrusted metadata everywhere (no verification mechanism exists yet — a foundation for future enforcement, per [App](#app)), never a permission input. `permissions` at create time is consistent with `setPermissions()`'s existing owner-or-creator policy — a contributor authoring a Record in your Stack can already widen its access up to and including `public`; create-time is not a new capability, just the same one exercised earlier.

Reading or writing a Record that exists but isn't accessible throws `StackPermissionError`; a missing Record throws `StackNotFoundError`, so callers can distinguish "not found" from "forbidden" (typically 404 vs 403 at the HTTP layer).

Plain `Stack` methods remain unscoped and perform no permission checks — correct for single-entity embedded use, where there's no requester distinct from the app itself. Use `asEntity()` when one `Stack` instance serves requests from multiple, possibly untrusted, entities, e.g. a server adapter.
Expand Down
162 changes: 119 additions & 43 deletions packages/core/src/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1583,6 +1583,99 @@ export class ScopedStack implements StackClient {
return record;
}

/**
* Whether the requester may create a reference to `recordId` (as a
* `parentId` or a `relationship` association target). Missing and
* unreadable both return false — indistinguishable, so this can't be used
* to probe for a record's existence (#51).
*/
private async canReadReferent(recordId: string): Promise<boolean> {
const record = await this.stack.get(recordId);
if (!record) return false;
return this.canRead(record);
}

/**
* Whether the requester may create a reference (`attachment` association
* or file-ref content field) to `fileId` — the dual of getAttachment()'s
* access rule: reference creation requires exactly what reference
* possession would grant. A nonexistent fileId and an existing-but-
* inaccessible one are indistinguishable here (both false), so this can't
* become a confirmation oracle for guessed content hashes (#51).
*/
private async canAccessFile(fileId: string): Promise<boolean> {
if (this.requesterEntityId === this.stack.ownerEntityId) return true;

const refResult = await this.query({ filter: { attachmentFileId: fileId }, limit: 1 });
if (refResult.records.length > 0) return true;

if (!this.requesterEntityId) return false;

return this.stack.features.contentFieldQuery
? (
await this.stack.query({
filter: {
typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
entityId: this.requesterEntityId,
content: { fileId },
},
limit: 1,
})
).records.length > 0
: (
await queryAllPages((q) => this.stack.query(q), {
filter: { typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`, entityId: this.requesterEntityId },
})
).some((r) => (r.content as AttachmentContent).fileId === fileId);
}

/** Names of the type's top-level file-ref fields — the content-reference half of attachmentFileId matching (#63). */
private async fileRefFieldNames(typeId: TypeId): Promise<string[]> {
const type = await this.stack.getType(typeId);
if (!type) return [];
return Object.entries(type.schema)
.filter(([, def]) => def.kind === 'file-ref')
.map(([field]) => field);
}

/**
* Gates file-ref content fields on file access, mirroring the attachment-
* association gate below — #63 made a file-ref field convey attachment
* access exactly like an `attachment` association does, so it needs the
* same reference-creation check (#51). Only fields actually present in
* `content` are checked: on update() that's a merge patch, so untouched
* fields carry no new reference.
*/
private async requireFileRefAccess(
typeId: TypeId,
content: Record<string, unknown | null>,
): Promise<void> {
for (const field of await this.fileRefFieldNames(typeId)) {
const value = content[field];
if (typeof value !== 'string') continue;
if (!(await this.canAccessFile(value))) throw new StackPermissionError();
}
}

/**
* Gates a single association's reference-creation check per #51: an
* `attachment` association requires file access, a `relationship`
* association requires read access to its target, a `tag` carries no
* reference and is unchecked.
*
* `_group` roster associations are exempt from the relationship check —
* their `recordId` is an entity ID, not a readable record, and roster
* mutation is already gated by isGroupManager() (#58), which is strictly
* tighter than "can read the target".
*/
private async requireAssociationAccess(typeId: TypeId, association: Association): Promise<void> {
if (association.kind === 'attachment') {
if (!(await this.canAccessFile(association.fileId))) throw new StackPermissionError();
} else if (association.kind === 'relationship' && baseIdOf(typeId) !== SYSTEM_TYPES.GROUP) {
if (!(await this.canReadReferent(association.recordId))) throw new StackPermissionError();
}
}

/**
* Create a new record on behalf of the authenticated requester.
* Requires either an entity-specific _grant or a default _grant for
Expand All @@ -1597,6 +1690,16 @@ export class ScopedStack implements StackClient {
* `_group` records additionally get the creator stamped as their first
* `admin` roster association, so a group is never management-orphaned —
* without this, nobody could ever pass isGroupManager() to add themselves.
*
* `parentId`, `associations`, and file-ref content fields are all
* reference-creating options a caller could otherwise use to piggyback on
* a bare `create` grant: a `parentId` or `relationship` association
* requires read access to the target, and an `attachment` association or
* file-ref field requires file access — the same checks associate()
* applies post-create (#51). `permissions` and `appId` are deliberately
* left unchecked here: `permissions` is create-time-consistent with
* setPermissions() (owner/creator territory already), and `appId` is
* self-reported, untrusted metadata everywhere, not a permission input.
*/
async create<T extends Record<string, unknown> = Record<string, unknown>>(
typeId: TypeId,
Expand All @@ -1612,6 +1715,13 @@ export class ScopedStack implements StackClient {
validateRecordId(opts.id);
validateIdTimestampSkew(opts.id, this.idTimestampSkewMs);
}
if (opts.parentId !== undefined && !(await this.canReadReferent(opts.parentId))) {
throw new StackPermissionError();
}
for (const assoc of opts.associations ?? []) {
await this.requireAssociationAccess(typeId, assoc);
}
await this.requireFileRefAccess(typeId, content);
const createOpts =
baseIdOf(typeId) === SYSTEM_TYPES.GROUP
? { ...opts, associations: stampGroupAdmin(opts.associations, requester) }
Expand Down Expand Up @@ -1672,7 +1782,8 @@ export class ScopedStack implements StackClient {
content: Record<string, unknown | null>,
opts: IfVersionOptions = {},
): Promise<StackRecord> {
await this.requireUpdatable(id);
const record = await this.requireUpdatable(id);
await this.requireFileRefAccess(record.typeId, content);
return this.stack.update(id, content, opts);
}

Expand All @@ -1681,7 +1792,8 @@ export class ScopedStack implements StackClient {
association: Association,
opts: IfVersionOptions = {},
): Promise<void> {
await this.requireUpdatable(id);
const record = await this.requireUpdatable(id);
await this.requireAssociationAccess(record.typeId, association);
return this.stack.associate(id, association, opts);
}

Expand Down Expand Up @@ -1805,49 +1917,13 @@ export class ScopedStack implements StackClient {
/**
* Download attachment bytes. Accessible if the requester is the owner,
* can read any record referencing the file, or uploaded the file themselves
* and it hasn't been associated with a record yet.
* and it hasn't been associated with a record yet. Shares its predicate
* with the reference-creation gate (canAccessFile, #51) — reference
* creation requires exactly what reference possession grants.
*/
async getAttachment(fileId: string): Promise<Uint8Array> {
if (this.requesterEntityId === this.stack.ownerEntityId) {
return this.stack.getAttachment(fileId);
}

// Accessible if the requester can read any record that references this file
const refResult = await this.query({ filter: { attachmentFileId: fileId }, limit: 1 });
if (refResult.records.length > 0) {
return this.stack.getAttachment(fileId);
}

// Accessible if the requester uploaded it and it hasn't been associated yet.
// With contentFieldQuery, the filter narrows to this fileId server-side, so
// limit: 1 is a correct existence check. Without it, matching happens in
// memory below — limit: 1 there would only ever see the requester's single
// most recent upload, false-denying every earlier one, so that path
// cursor-walks all of the requester's uploads instead.
if (this.requesterEntityId) {
const hasUpload = this.stack.features.contentFieldQuery
? (
await this.stack.query({
filter: {
typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
entityId: this.requesterEntityId,
content: { fileId },
},
limit: 1,
})
).records.length > 0
: (
await queryAllPages((q) => this.stack.query(q), {
filter: {
typeId: `${SYSTEM_TYPES.ATTACHMENT}@1`,
entityId: this.requesterEntityId,
},
})
).some((r) => (r.content as AttachmentContent).fileId === fileId);
if (hasUpload) return this.stack.getAttachment(fileId);
}

throw new StackPermissionError();
if (!(await this.canAccessFile(fileId))) throw new StackPermissionError();
return this.stack.getAttachment(fileId);
}

/**
Expand Down
Loading
Loading