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

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions packages/data/src/auth/evaluator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,55 @@ describe('GrantIndex', () => {
})

describe('DefaultPolicyEvaluator', () => {
it('honors hub grants for non-owners on legacy schemas (0192 Landmine #1)', async () => {
const alice = createIdentity()
const bob = createIdentity()
const carol = createIdentity()
const store = await createStore(alice)

const LegacyDoc = defineSchema({
name: 'AuthLegacyDoc',
namespace: 'xnet://tests/',
properties: { title: text({ required: true }) }
})
const schemaRegistry = new SchemaRegistry()
schemaRegistry.register(LegacyDoc)

const node = await store.create({
schemaId: LegacyDoc.schema['@id'],
properties: { title: 'shared' }
})
await store.create({
schemaId: GRANT_SCHEMA_ID,
properties: {
grantee: bob.did,
resource: node.id,
actions: JSON.stringify(['read']),
revokedAt: 0,
expiresAt: Date.now() + 10_000
}
})

const grantIndex = new GrantIndex(store)
await grantIndex.initialize()
const evaluator = new DefaultPolicyEvaluator({ store, schemaRegistry, grantIndex })

// Owner is always allowed.
expect(
(await evaluator.can({ subject: alice.did, action: 'read', nodeId: node.id })).allowed
).toBe(true)
// Non-owner WITH a grant is allowed — the fix. Previously a flat deny here
// hid grant-shared legacy-schema nodes from their collaborators.
expect(
(await evaluator.can({ subject: bob.did, action: 'read', nodeId: node.id })).allowed
).toBe(true)
// Non-owner WITHOUT a grant stays denied.
expect(
(await evaluator.can({ subject: carol.did, action: 'read', nodeId: node.id })).allowed
).toBe(false)
grantIndex.dispose()
})

it('allows relation-derived roles and caches decisions', async () => {
const alice = createIdentity()
const bob = createIdentity()
Expand Down
36 changes: 26 additions & 10 deletions packages/data/src/auth/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,17 +478,33 @@ export class DefaultPolicyEvaluator implements PolicyEvaluator {
return decision
}

// `getAuthMode` returns 'legacy' exactly when the schema has no
// authorization block; the `!authorization` disjunct is redundant at
// runtime but lets the compiler narrow `authorization` to defined below.
const mode = getAuthMode(schema.schema)
if (mode === 'legacy') {
const allowed = node.createdBy === input.subject
const decision = this.decision(input, allowed, allowed ? ['owner'] : [], start)
this.cache.set(input.subject, input.action, input.nodeId, decision)
this.emitDecision(decision)
return decision
}

if (!schema.schema.authorization) {
const decision = this.deny(input, ['DENY_NO_ROLE_MATCH'], start)
if (mode === 'legacy' || !schema.schema.authorization) {
// Owner is always allowed.
if (node.createdBy === input.subject) {
const decision = this.decision(input, true, ['owner'], start)
this.cache.set(input.subject, input.action, input.nodeId, decision)
this.emitDecision(decision)
return decision
}
// Non-owner: fall through to the grant index so hub/share grants still
// apply. Returning a flat deny here (the old behavior) silently ignored
// valid grants, making any shared legacy-schema node invisible to its
// collaborators (exploration 0192, Landmine #1).
const grant = this.findMatchingGrant(
input,
this.grantIndex?.findGrants(input.nodeId, input.subject) ?? []
)
if (grant) {
const decision = this.decision(input, true, [], start, [grant.id])
this.cache.set(input.subject, input.action, input.nodeId, decision)
this.emitDecision(decision)
return decision
}
const decision = this.deny(input, ['DENY_NO_ROLE_MATCH', 'DENY_NO_GRANT'], start)
this.emitDecision(decision)
return decision
}
Expand Down
65 changes: 65 additions & 0 deletions packages/data/src/auth/hub-policy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Schema ↔ hub grant parity (exploration 0192).
*
* The Space cascade (`spaceCascadeAuthorization`, declared on content schemas)
* and the hub's `spaceRoleGrantActions` mapping must describe the same access
* ladder. This test derives the hub actions implied by the schema and asserts
* they match `spaceRoleGrantActions` for every Space role — so changing one
* without the other turns CI red.
*/
import { describe, expect, it } from 'vitest'
import { defineSchema } from '../schema/define'
import { TaskSchema, SPACE_ROLES, spaceRoleGrantActions } from '../schema/schemas'
import { schemaToHubPolicy, hubActionsForSpaceRole } from './hub-policy'
import { allow, role } from '.'

describe('schemaToHubPolicy', () => {
it('projects schema actions onto the hub grant vocabulary (delete → admin)', () => {
const policy = schemaToHubPolicy(TaskSchema.schema)
// The cascade grants admins read+write+delete+share → read/write/admin/share.
expect(policy.roleActions.spaceAdmin).toEqual(['admin', 'read', 'share', 'write'])
// Viewers only read.
expect(policy.roleActions.spaceViewer).toEqual(['read'])
expect(policy.public).toBe(false)
})

it('reports public read when the schema grants PUBLIC', () => {
const PublicDoc = defineSchema({
name: 'PublicDoc',
namespace: 'xnet://test/',
properties: {},
authorization: {
roles: { owner: role.creator() },
actions: {
read: { _tag: 'public' },
write: allow('owner'),
delete: allow('owner'),
share: allow('owner')
}
}
})
expect(schemaToHubPolicy(PublicDoc.schema).public).toBe(true)
})

it('returns an empty policy for legacy (authorization-less) schemas', () => {
const Legacy = defineSchema({
name: 'Legacy',
namespace: 'xnet://test/',
properties: {}
})
expect(schemaToHubPolicy(Legacy.schema)).toEqual({ roleActions: {}, public: false })
})
})

describe('schema ↔ hub grant parity', () => {
it('cascade hub actions match spaceRoleGrantActions for every Space role', () => {
for (const spaceRole of SPACE_ROLES) {
const fromSchema = new Set(hubActionsForSpaceRole(TaskSchema.schema, spaceRole))
// `comment` is a hub-only refinement of `read` not modeled by the cascade.
const expected = new Set(
spaceRoleGrantActions(spaceRole).filter((action) => action !== 'comment')
)
expect(fromSchema, `space role: ${spaceRole}`).toEqual(expected)
}
})
})
88 changes: 88 additions & 0 deletions packages/data/src/auth/hub-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Derive the hub's grant-action expectations from a schema's authorization
* block (exploration 0192).
*
* The hub enforces access with a grant model whose vocabulary is
* `read | comment | write | share | admin` (see `spaceRoleGrantActions`). The
* schema authorization DSL speaks `read | write | delete | share`. These two
* have historically been maintained by hand in separate places and were free
* to drift. `schemaToHubPolicy` projects a schema's declared per-action role
* sets onto the hub vocabulary so the relationship can be asserted in CI
* (`hub-policy.test.ts`) — the schema authorization block becomes the single
* declarative source the hub mapping is checked against.
*
* Correspondence:
* read → read write → write share → share delete → admin
*
* `comment` is a hub-only refinement of `read` (a commenter may annotate but
* not edit). The Space cascade folds commenters into `read` and does not model
* a distinct `comment` action, so it is intentionally absent here.
*/
import type { Schema } from '../schema/types'
import { deserializeAuthorization } from './serialize'
import { extractRoleRefs, hasPublicAccess } from './validate'

/** Maps a schema authorization action onto the hub grant-action vocabulary. */
const SCHEMA_ACTION_TO_HUB: Readonly<Record<string, string>> = {
read: 'read',
write: 'write',
share: 'share',
delete: 'admin'
}

export interface HubPolicy {
/** For each role declared by the schema, the hub grant actions it implies. */
roleActions: Record<string, string[]>
/** True when the schema's `read` action grants PUBLIC. */
public: boolean
}

/** Project a schema's authorization block onto the hub grant-action model. */
export function schemaToHubPolicy(schema: Schema): HubPolicy {
if (!schema.authorization) {
return { roleActions: {}, public: false }
}

const auth = deserializeAuthorization(schema.authorization)
const roleActions: Record<string, Set<string>> = {}
let isPublic = false

for (const [action, expr] of Object.entries(auth.actions)) {
if (!expr) continue
if (action === 'read' && hasPublicAccess(expr)) {
isPublic = true
}
const hubAction = SCHEMA_ACTION_TO_HUB[action]
if (!hubAction) continue
for (const roleName of extractRoleRefs(expr)) {
const actions = (roleActions[roleName] ??= new Set<string>())
actions.add(hubAction)
}
}

return {
roleActions: Object.fromEntries(
Object.entries(roleActions).map(([role, actions]) => [role, [...actions].sort()])
),
public: isPublic
}
}

/** Capitalize the first letter — `viewer` → `Viewer`. */
function capitalize(value: string): string {
return value.length === 0 ? value : `${value[0].toUpperCase()}${value.slice(1)}`
}

/**
* Hub grant actions a Space *member* with the given Space role inherits on a
* node governed by the Space cascade, derived purely from the schema. A member
* with role `R` resolves to the cascade role `spaceR` (e.g. `viewer` →
* `spaceViewer`), so this looks up that role's projected hub actions.
*
* Used by the parity test to prove the cascade and `spaceRoleGrantActions`
* agree, so neither can change without the other.
*/
export function hubActionsForSpaceRole(schema: Schema, spaceRole: string): string[] {
const cascadeRole = `space${capitalize(spaceRole)}`
return schemaToHubPolicy(schema).roleActions[cascadeRole] ?? []
}
3 changes: 3 additions & 0 deletions packages/data/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ export {
export type { AuthMode } from './mode'
export { getAuthMode, warnLegacySchema } from './mode'

// Schema → hub grant-action projection + parity (exploration 0192)
export { schemaToHubPolicy, hubActionsForSpaceRole, type HubPolicy } from './hub-policy'

// Recipient computation + migration helpers
export {
PUBLIC_CONTENT_KEY,
Expand Down
16 changes: 16 additions & 0 deletions packages/data/src/auth/recipients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ describe('computeRecipients', () => {
expect(recipients).toEqual([OWNER_DID])
})

it('includes grant recipients even for legacy schemas (0192 Landmine #3)', async () => {
const recipients = await computeRecipients(legacySchema(), createNodeState(), {
getNode: async () => null,
grantIndex: {
findGrantsForResource: () => [
{
id: 'grant-1',
properties: { actions: JSON.stringify(['read']), grantee: GRANTEE_DID }
}
]
}
})

expect(new Set(recipients)).toEqual(new Set([OWNER_DID, GRANTEE_DID]))
})

it('returns PUBLIC sentinel when read expression is public', async () => {
const TaskSchema = defineSchema({
name: 'Task',
Expand Down
30 changes: 20 additions & 10 deletions packages/data/src/auth/recipients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,33 @@ export async function computeRecipients(
const recipients = new Set<Recipient>()
recipients.add(node.createdBy)

// Grant-index recipients apply regardless of whether the schema declares
// roles: a node shared purely via a hub/share grant must still reach its
// grantees, including legacy (authorization-less) schemas. Folding this in
// for every path fixes the owner-only lockout (exploration 0192, Landmine #3).
const addGrantRecipients = () => {
const grants = dependencies.grantIndex?.findGrantsForResource(node.id) ?? []
for (const grant of grants) {
const actions = parseGrantActions(grant.properties.actions)
if (actions.includes('read') || actions.includes('write')) {
const grantee = grant.properties.grantee
if (typeof grantee === 'string' && grantee.startsWith('did:key:')) {
recipients.add(grantee as DID)
}
}
}
}

if (!schema.authorization) {
addGrantRecipients()
return [...recipients]
}

const auth = deserializeAuthorization(schema.authorization)
const readExpr = auth.actions.read

if (!readExpr) {
addGrantRecipients()
return [...recipients]
}

Expand All @@ -64,16 +83,7 @@ export async function computeRecipients(
}
}

const grants = dependencies.grantIndex?.findGrantsForResource(node.id) ?? []
for (const grant of grants) {
const actions = parseGrantActions(grant.properties.actions)
if (actions.includes('read') || actions.includes('write')) {
const grantee = grant.properties.grantee
if (typeof grantee === 'string' && grantee.startsWith('did:key:')) {
recipients.add(grantee as DID)
}
}
}
addGrantRecipients()

return [...recipients]
}
Expand Down
5 changes: 4 additions & 1 deletion packages/data/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,10 @@ export {
describeRoleResolver,
type PermissionMatrix,
type ActionPermission,
type RoleSummary
type RoleSummary,
schemaToHubPolicy,
hubActionsForSpaceRole,
type HubPolicy
} from './auth'

// Blob service
Expand Down
13 changes: 12 additions & 1 deletion packages/data/src/schema/define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import type {
InferNode
} from './types'
import type { AuthorizationDefinition } from '@xnetjs/core'
import { validateAuthorization, serializeAuthorization } from '../auth'
import { validateAuthorization, serializeAuthorization, warnLegacySchema } from '../auth'
import { createNodeId } from './node'
import { isAuthExemptSchema } from './schemas/auth-exempt'

/**
* Default schema version when not specified.
Expand Down Expand Up @@ -152,6 +153,16 @@ export function defineSchema<P extends Record<string, PropertyBuilder>>(
authorization: options.authorization ? serializeAuthorization(options.authorization) : undefined
}

// ─── Dev-time guard rail: every schema should declare an authorization block
// (or be on the intentional auth-exempt allowlist). Without one the engine
// treats nodes as owner-only "legacy" mode (exploration 0192). The coverage
// test enforces this in CI; this warning surfaces it while authoring in the
// dev server. Gated to `development` (not merely non-production) so the many
// ad-hoc minimal schemas defined inside test suites don't spam warnings.
if (process.env.NODE_ENV === 'development' && !isAuthExemptSchema(schemaId)) {
warnLegacySchema(schema)
}

// Validation function
function validate(node: unknown): ValidationResult {
const errors: ValidationError[] = []
Expand Down
Loading
Loading