1import { schema as s } from "jazz-tools";2import type { CompiledPermissions } from "jazz-tools";3import { isEncryptedColumn } from "../schema/encrypted.ts";4import { AccessError } from "./errors.ts";56/** Minimum declared Jazz table metadata consumed by access templates. */7export type AccessTable = {8 readonly _table: string;9 readonly _schema: Record<string, { columns?: unknown[] }>;10};1112/** Owner-only resource policy template. */13export type PrivateAccessTemplate = { readonly kind: "private"; readonly resource: AccessTable };14/** Direct-share resource and grant-table policy template. */15export type SharedAccessTemplate = {16 readonly kind: "shared";17 readonly resource: AccessTable;18 readonly grants: AccessTable;19};20/** Fixed-role group, membership, and resource policy template. */21export type GroupAccessTemplate = {22 readonly kind: "group";23 readonly groups: AccessTable;24 readonly members: AccessTable;25 readonly resources: readonly AccessTable[];26 readonly groupId: string;27 /** The group's wrapped-field-key table, when it hosts shared columns. */28 readonly fieldKeys?: AccessTable;29};30/** Shared-field key-directory policy template. */31export type SharedFieldAccessTemplate = {32 readonly kind: "shared-field";33 readonly directory: AccessTable;34};35/** Any built-in access policy template accepted by {@link defineAccessPolicies}. */36export type AccessTemplate =37 | PrivateAccessTemplate38 | SharedAccessTemplate39 | GroupAccessTemplate40 | SharedFieldAccessTemplate;4142/**43 * Declares owner-only read and mutation policy for one resource table.44 *45 * @example46 * ```ts47 * import { defineAccessPolicies, privateAccess } from "@nzip/lofi/access";48 * import { app } from "./schema.ts";49 *50 * export default defineAccessPolicies(app, [privateAccess({ resource: app.notes })]);51 * ```52 *53 * @param config The resource table whose rows are visible and mutable only to their creator.54 * @returns A template for {@link defineAccessPolicies}.55 */56export function privateAccess(config: { resource: AccessTable }): PrivateAccessTemplate {57 return { kind: "private", ...config };58}5960/**61 * Declares owner plus explicit read/edit grants for one resource table.62 *63 * The grant table must be declared with the `sharedGrantTable` helper (or match64 * its column shape) and reference the resource table.65 *66 * @example67 * ```ts68 * import { defineAccessPolicies, sharedAccess } from "@nzip/lofi/access";69 * import { app } from "./schema.ts";70 *71 * export default defineAccessPolicies(app, [72 * sharedAccess({ resource: app.notes, grants: app.noteGrants }),73 * ]);74 * ```75 *76 * @param config The shared resource table and its grant table.77 * @returns A template for {@link defineAccessPolicies}.78 */79export function sharedAccess(config: {80 resource: AccessTable;81 grants: AccessTable;82}): SharedAccessTemplate {83 return { kind: "shared", ...config };84}8586/**87 * Declares fixed-role membership policy for one or more group-owned resources.88 *89 * The membership table must be declared with the `groupMembershipTable` helper90 * (or match its column shape), and each resource table must carry the `groupId`91 * column referencing the group table.92 *93 * Group-creator authority is permanent: the creator can always update their94 * group row, and through it restore their own admin membership even after95 * being demoted or removed. Choose this template only when that trust96 * property fits — a group cannot durably expel its creator.97 *98 * @example99 * ```ts100 * import { defineAccessPolicies, groupAccess } from "@nzip/lofi/access";101 * import { app } from "./schema.ts";102 *103 * export default defineAccessPolicies(app, [groupAccess({104 * groups: app.workspaces,105 * members: app.workspaceMembers,106 * resources: app.documents,107 * groupId: "workspaceId",108 * })]);109 * ```110 *111 * @param config The group table, membership table, group-owned resource table(s), and the112 * resource column that references the group.113 * @returns A template for {@link defineAccessPolicies}.114 */115export function groupAccess(config: {116 groups: AccessTable;117 members: AccessTable;118 resources: AccessTable | readonly AccessTable[];119 groupId: string;120 /** Wrapped-field-key table for groups hosting shared encrypted columns121 * (declared with `sharedFieldKeyTable`). Readable by each row's recipient122 * or any member; inserted by a member signing as themselves; updated only123 * by the row's sender; deleted by the sender, the recipient, or a group124 * admin. */125 fieldKeys?: AccessTable;126}): GroupAccessTemplate {127 return {128 kind: "group",129 groups: config.groups,130 members: config.members,131 resources: Array.isArray(config.resources) ? config.resources : [config.resources],132 groupId: config.groupId,133 ...(config.fieldKeys ? { fieldKeys: config.fieldKeys } : {}),134 };135}136137/**138 * Declares the shared-field key directory policy: every authenticated139 * account reads the directory (public keys are public — integrity comes from140 * fingerprint pinning), and each account writes only its own row, so the141 * store cannot be used to impersonate a publisher through the policy layer.142 *143 * The directory table must be declared with the `sharedFieldDirectoryTable`144 * helper (or match its column shape).145 *146 * @param config The key directory table.147 * @returns A template for {@link defineAccessPolicies}.148 */149export function sharedFieldAccess(config: { directory: AccessTable }): SharedFieldAccessTemplate {150 return { kind: "shared-field", ...config };151}152153type Column = {154 name?: string;155 references?: string;156 column_type?: { type?: string; variants?: string[] };157};158159function columns(table: AccessTable): Column[] {160 const definition = table._schema[table._table];161 return (definition?.columns ?? []) as Column[];162}163164function requireColumn(165 table: AccessTable,166 name: string,167 type: string,168 references?: string,169): Column {170 if (isEncryptedColumn(table._table, name)) {171 throw new AccessError(172 "configuration",173 `Access table ${table._table} declares ${name} as an encrypted column; template columns ` +174 "are evaluated by the server, which cannot read ciphertext. Use a plaintext column.",175 );176 }177 const column = columns(table).find((candidate) => candidate.name === name);178 if (!column || column.column_type?.type !== type || column.references !== references) {179 const reference = references ? ` referencing ${references}` : "";180 throw new AccessError(181 "configuration",182 `Access table ${table._table} must contain ${name} as ${type}${reference}. Use the matching @nzip/lofi/access table helper or correct the raw Jazz schema.`,183 );184 }185 return column;186}187188function validateTemplate(template: AccessTemplate): void {189 if (template.kind === "shared") {190 requireColumn(template.grants, "resourceId", "Uuid", template.resource._table);191 requireColumn(template.grants, "user_id", "Text");192 requireColumn(template.grants, "can_edit", "Boolean");193 }194 if (template.kind === "shared-field") {195 requireColumn(template.directory, "user_id", "Text");196 requireColumn(template.directory, "algo", "Text");197 requireColumn(template.directory, "public_key", "Text");198 requireColumn(template.directory, "fingerprint", "Text");199 }200 if (template.kind === "group") {201 requireColumn(template.members, "groupId", "Uuid", template.groups._table);202 requireColumn(template.members, "user_id", "Text");203 requireColumn(template.members, "role", "Text");204 requireColumn(template.members, "can_create", "Boolean");205 requireColumn(template.members, "can_edit_any", "Boolean");206 requireColumn(template.members, "can_manage", "Boolean");207 for (const resource of template.resources) {208 requireColumn(resource, template.groupId, "Uuid", template.groups._table);209 }210 if (template.fieldKeys) {211 requireColumn(template.fieldKeys, "groupId", "Uuid", template.groups._table);212 requireColumn(template.fieldKeys, "recipient_user_id", "Text");213 requireColumn(template.fieldKeys, "sender_user_id", "Text");214 requireColumn(template.fieldKeys, "generation", "Integer");215 requireColumn(template.fieldKeys, "wrapped_key", "Text");216 requireColumn(template.fieldKeys, "recipient_fingerprint", "Text");217 requireColumn(template.fieldKeys, "sender_fingerprint", "Text");218 }219 }220}221222/** Minimal Jazz rule builder exposed to raw access-policy extensions. */223export type RuleBuilder = {224 where(input: unknown): unknown;225 always(): unknown;226};227/** Read and mutation policy builders for one declared table. */228export type TablePolicy = {229 allowRead: RuleBuilder;230 allowInsert: RuleBuilder;231 allowUpdate: RuleBuilder;232 allowDelete: RuleBuilder;233 exists: { where(input: unknown): unknown };234};235/** Raw Jazz policy-builder context exposed to advanced policy extensions. */236export type RawAccessPolicyContext = {237 policy: Record<string, TablePolicy>;238 session: { user_id: unknown };239 anyOf(conditions: readonly unknown[]): unknown;240 allOf(conditions: readonly unknown[]): unknown;241 allowedTo: {242 update(fkColumn: string): unknown;243 };244};245246/** Callback for app-specific rules that do not fit the built-in templates. */247export type RawAccessPolicyExtension = (context: RawAccessPolicyContext) => void;248249// A policy referencing an encrypted column would compile but silently never250// match — the server compares ciphertext it cannot read — so every rule251// builder refuses such conditions at compile time. Object conditions are252// checked directly; function conditions have their returned condition checked253// against the same table, and any condition they build for another table254// passes through that table's own guarded builder.255function assertNoEncryptedConditionKeys(tableName: string, input: unknown): void {256 if (typeof input !== "object" || input === null || Array.isArray(input)) return;257 for (const key of Object.keys(input)) {258 if (key.startsWith("$")) continue;259 if (isEncryptedColumn(tableName, key)) {260 throw new AccessError(261 "configuration",262 `Policy for ${tableName} filters on encrypted column ${key}; the server cannot ` +263 "evaluate what it cannot read. Gate on a plaintext column instead.",264 );265 }266 }267}268269function guardedWhere(270 tableName: string,271 rule: { where(input: unknown): unknown },272): (input: unknown) => unknown {273 return (input: unknown) => {274 if (typeof input === "function") {275 return rule.where((row: unknown) => {276 const condition = (input as (row: unknown) => unknown)(row);277 assertNoEncryptedConditionKeys(tableName, condition);278 return condition;279 });280 }281 assertNoEncryptedConditionKeys(tableName, input);282 return rule.where(input);283 };284}285286function guardTablePolicy(tableName: string, tablePolicy: TablePolicy): TablePolicy {287 const guardRule = (rule: RuleBuilder): RuleBuilder => ({288 where: guardedWhere(tableName, rule),289 always: () => rule.always(),290 });291 return {292 allowRead: guardRule(tablePolicy.allowRead),293 allowInsert: guardRule(tablePolicy.allowInsert),294 allowUpdate: guardRule(tablePolicy.allowUpdate),295 allowDelete: guardRule(tablePolicy.allowDelete),296 exists: { where: guardedWhere(tableName, tablePolicy.exists) },297 };298}299300function guardPolicies(policy: Record<string, TablePolicy>): Record<string, TablePolicy> {301 return new Proxy(policy, {302 get(target, property, receiver) {303 const value = Reflect.get(target, property, receiver);304 if (typeof property !== "string" || typeof value !== "object" || value === null) {305 return value;306 }307 return guardTablePolicy(property, value as TablePolicy);308 },309 });310}311312/**313 * Compiles the four narrow templates through Jazz's own policy builder. The314 * optional callback is the raw Jazz-policy escape hatch for app-specific rules.315 *316 * @example317 * ```ts318 * import { defineAccessPolicies, groupAccess, sharedAccess } from "@nzip/lofi/access";319 * import { app } from "./schema.ts";320 *321 * export default defineAccessPolicies(app, [322 * sharedAccess({ resource: app.notes, grants: app.noteGrants }),323 * groupAccess({324 * groups: app.workspaces,325 * members: app.workspaceMembers,326 * resources: app.documents,327 * groupId: "workspaceId",328 * }),329 * ]);330 * ```331 *332 * @param app The raw Jazz app returned by `s.defineApp`.333 * @param templates At least one {@link privateAccess}, {@link sharedAccess},334 * {@link groupAccess}, or {@link sharedFieldAccess} template; every table needs335 * a policy. `sharedFieldAccess` compiles the key directory behind shared336 * encrypted columns; pair it with the `fieldKeys` option of337 * {@link groupAccess}, and group operations created with the same tables run338 * the key lifecycle (bootstrap on creation, wrap delivery, lazy rotation, and339 * `reconcileSharedFieldKeys` repair).340 * @param raw Optional raw-policy callback for app-specific rules beyond the templates.341 * @returns Compiled permissions suitable as the app's default policy export.342 */343export function defineAccessPolicies<TApp extends object>(344 app: TApp,345 templates: readonly AccessTemplate[],346 raw?: RawAccessPolicyExtension,347): CompiledPermissions {348 if (templates.length === 0) {349 throw new AccessError("configuration", "defineAccessPolicies requires at least one template.");350 }351 for (const template of templates) validateTemplate(template);352353 return s.definePermissions(app, (jazzContext) => {354 const rawContext = jazzContext as unknown as RawAccessPolicyContext;355 const guarded: RawAccessPolicyContext = {356 ...rawContext,357 policy: guardPolicies(rawContext.policy),358 };359 const { policy, session, anyOf, allOf, allowedTo } = guarded;360 for (const template of templates) {361 if (template.kind === "private") {362 const resource = policy[template.resource._table];363 resource.allowInsert.always();364 resource.allowRead.where({ $createdBy: session.user_id });365 resource.allowUpdate.where({ $createdBy: session.user_id });366 resource.allowDelete.where({ $createdBy: session.user_id });367 continue;368 }369 if (template.kind === "shared-field") {370 const directory = policy[template.directory._table];371 directory.allowRead.always();372 directory.allowInsert.where({ user_id: session.user_id });373 directory.allowUpdate.where({ user_id: session.user_id });374 directory.allowDelete.where({ user_id: session.user_id });375 continue;376 }377 if (template.kind === "shared") {378 const resource = policy[template.resource._table];379 const grants = policy[template.grants._table];380 const ownerOf = (resourceId: unknown) =>381 resource.exists.where({ id: resourceId, $createdBy: session.user_id });382 resource.allowInsert.always();383 resource.allowRead.where((row: Record<string, unknown>) =>384 anyOf([385 { $createdBy: session.user_id },386 grants.exists.where({ resourceId: row.id, user_id: session.user_id }),387 ])388 );389 resource.allowUpdate.where((row: Record<string, unknown>) =>390 anyOf([391 { $createdBy: session.user_id },392 grants.exists.where({393 resourceId: row.id,394 user_id: session.user_id,395 can_edit: true,396 }),397 ])398 );399 resource.allowDelete.where({ $createdBy: session.user_id });400 grants.allowRead.where((grant: Record<string, unknown>) =>401 anyOf([{ user_id: session.user_id }, ownerOf(grant.resourceId)])402 );403 grants.allowInsert.where((grant: Record<string, unknown>) => ownerOf(grant.resourceId));404 grants.allowUpdate.where((grant: Record<string, unknown>) => ownerOf(grant.resourceId));405 grants.allowDelete.where((grant: Record<string, unknown>) => ownerOf(grant.resourceId));406 continue;407 }408409 const groups = policy[template.groups._table];410 const members = policy[template.members._table];411 const isMember = (groupId: unknown) =>412 members.exists.where({ groupId, user_id: session.user_id });413 const hasCapability = (groupId: unknown, capability: string) =>414 members.exists.where({ groupId, user_id: session.user_id, [capability]: true });415 const isAdmin = (groupId: unknown) => hasCapability(groupId, "can_manage");416 // Group-creator authority is PERMANENT by design: the creator can always417 // update the group row, and — because membership management derives from418 // group-update via `allowedTo.update` — can always restore their own419 // admin membership after demotion or removal. This is a documented trust420 // property of the template, not an oversight: scoping creator authority421 // to a bootstrap window ("only while no admin membership exists")422 // requires a negated existence condition, and the pinned Jazz alpha.53423 // policy engine silently drops Not around Exists/ExistsRel — the window424 // cannot be enforced at the policy boundary. The engine canary in425 // access_security_test.ts fails when an upgrade fixes negation; implement426 // the window then. See docs/decisions/group-creator-authority-alpha53.md.427 //428 // Direct creator delete below grants no additional authority (a creator429 // can always self-bootstrap an admin membership and delete transitively);430 // it exists so group creation can roll back when the first admin insert431 // fails, instead of stranding an undeletable orphan row.432 groups.allowRead.where((group: Record<string, unknown>) =>433 anyOf([isMember(group.id), { $createdBy: session.user_id }])434 );435 groups.allowInsert.always();436 groups.allowUpdate.where((group: Record<string, unknown>) =>437 anyOf([{ $createdBy: session.user_id }, isAdmin(group.id)])438 );439 groups.allowDelete.where((group: Record<string, unknown>) =>440 anyOf([{ $createdBy: session.user_id }, isAdmin(group.id)])441 );442 members.allowRead.where(443 anyOf([444 { user_id: session.user_id },445 allowedTo.update("groupId"),446 ]),447 );448 members.allowInsert.where(allowedTo.update("groupId"));449 members.allowUpdate.where(allowedTo.update("groupId"));450 members.allowDelete.where(451 anyOf([allowedTo.update("groupId"), { user_id: session.user_id }]),452 );453 for (const table of template.resources) {454 const resource = policy[table._table];455 resource.allowRead.where((row: Record<string, unknown>) => isMember(row[template.groupId]));456 resource.allowInsert.where((row: Record<string, unknown>) =>457 hasCapability(row[template.groupId], "can_create")458 );459 resource.allowUpdate.where((row: Record<string, unknown>) =>460 anyOf([461 hasCapability(row[template.groupId], "can_edit_any"),462 allOf([463 { $createdBy: session.user_id },464 hasCapability(row[template.groupId], "can_create"),465 ]),466 ])467 );468 resource.allowDelete.where((row: Record<string, unknown>) =>469 anyOf([470 hasCapability(row[template.groupId], "can_edit_any"),471 allOf([472 { $createdBy: session.user_id },473 hasCapability(row[template.groupId], "can_create"),474 ]),475 ])476 );477 }478 if (template.fieldKeys) {479 // Wrapped field keys: recipients and members read (members see who480 // holds keys, which key repair needs); any member may wrap but only481 // as themselves — a malicious member is already inside the crypto482 // boundary, so restricting senders to admins buys nothing and hurts483 // availability. Recipients may shed their own wraps; admins clean up.484 const fieldKeys = policy[template.fieldKeys._table];485 fieldKeys.allowRead.where((row: Record<string, unknown>) =>486 anyOf([{ recipient_user_id: session.user_id }, isMember(row.groupId)])487 );488 fieldKeys.allowInsert.where((row: Record<string, unknown>) =>489 allOf([{ sender_user_id: session.user_id }, isMember(row.groupId)])490 );491 fieldKeys.allowUpdate.where({ sender_user_id: session.user_id });492 fieldKeys.allowDelete.where((row: Record<string, unknown>) =>493 anyOf([494 { sender_user_id: session.user_id },495 { recipient_user_id: session.user_id },496 isAdmin(row.groupId),497 ])498 );499 }500 }501 raw?.({ policy, session, anyOf, allOf, allowedTo });502 });503}