clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/prf-provision-custody
lofi / package / access / policies.ts
policies.ts 12.9K
1import { schema as s } from "jazz-tools";2import type { CompiledPermissions } from "jazz-tools";3import { AccessError } from "./errors.ts";45/** Minimum declared Jazz table metadata consumed by access templates. */6export type AccessTable = {7 readonly _table: string;8 readonly _schema: Record<string, { columns?: unknown[] }>;9};1011/** Owner-only resource policy template. */12export type PrivateAccessTemplate = { readonly kind: "private"; readonly resource: AccessTable };13/** Direct-share resource and grant-table policy template. */14export type SharedAccessTemplate = {15 readonly kind: "shared";16 readonly resource: AccessTable;17 readonly grants: AccessTable;18};19/** Fixed-role group, membership, and resource policy template. */20export type GroupAccessTemplate = {21 readonly kind: "group";22 readonly groups: AccessTable;23 readonly members: AccessTable;24 readonly resources: readonly AccessTable[];25 readonly groupId: string;26};27/** Any built-in access policy template accepted by {@link defineAccessPolicies}. */28export type AccessTemplate = PrivateAccessTemplate | SharedAccessTemplate | GroupAccessTemplate;2930/**31 * Declares owner-only read and mutation policy for one resource table.32 *33 * @example34 * ```ts35 * import { defineAccessPolicies, privateAccess } from "@nzip/lofi/access";36 * import { app } from "./schema.ts";37 *38 * export default defineAccessPolicies(app, [privateAccess({ resource: app.notes })]);39 * ```40 *41 * @param config The resource table whose rows are visible and mutable only to their creator.42 * @returns A template for {@link defineAccessPolicies}.43 */44export function privateAccess(config: { resource: AccessTable }): PrivateAccessTemplate {45 return { kind: "private", ...config };46}4748/**49 * Declares owner plus explicit read/edit grants for one resource table.50 *51 * The grant table must be declared with the `sharedGrantTable` helper (or match52 * its column shape) and reference the resource table.53 *54 * @example55 * ```ts56 * import { defineAccessPolicies, sharedAccess } from "@nzip/lofi/access";57 * import { app } from "./schema.ts";58 *59 * export default defineAccessPolicies(app, [60 * sharedAccess({ resource: app.notes, grants: app.noteGrants }),61 * ]);62 * ```63 *64 * @param config The shared resource table and its grant table.65 * @returns A template for {@link defineAccessPolicies}.66 */67export function sharedAccess(config: {68 resource: AccessTable;69 grants: AccessTable;70}): SharedAccessTemplate {71 return { kind: "shared", ...config };72}7374/**75 * Declares fixed-role membership policy for one or more group-owned resources.76 *77 * The membership table must be declared with the `groupMembershipTable` helper78 * (or match its column shape), and each resource table must carry the `groupId`79 * column referencing the group table.80 *81 * @example82 * ```ts83 * import { defineAccessPolicies, groupAccess } from "@nzip/lofi/access";84 * import { app } from "./schema.ts";85 *86 * export default defineAccessPolicies(app, [groupAccess({87 * groups: app.workspaces,88 * members: app.workspaceMembers,89 * resources: app.documents,90 * groupId: "workspaceId",91 * })]);92 * ```93 *94 * @param config The group table, membership table, group-owned resource table(s), and the95 * resource column that references the group.96 * @returns A template for {@link defineAccessPolicies}.97 */98export function groupAccess(config: {99 groups: AccessTable;100 members: AccessTable;101 resources: AccessTable | readonly AccessTable[];102 groupId: string;103}): GroupAccessTemplate {104 return {105 kind: "group",106 groups: config.groups,107 members: config.members,108 resources: Array.isArray(config.resources) ? config.resources : [config.resources],109 groupId: config.groupId,110 };111}112113type Column = {114 name?: string;115 references?: string;116 column_type?: { type?: string; variants?: string[] };117};118119function columns(table: AccessTable): Column[] {120 const definition = table._schema[table._table];121 return (definition?.columns ?? []) as Column[];122}123124function requireColumn(125 table: AccessTable,126 name: string,127 type: string,128 references?: string,129): Column {130 const column = columns(table).find((candidate) => candidate.name === name);131 if (!column || column.column_type?.type !== type || column.references !== references) {132 const reference = references ? ` referencing ${references}` : "";133 throw new AccessError(134 "configuration",135 `Access table ${table._table} must contain ${name} as ${type}${reference}. Use the matching @nzip/lofi/access table helper or correct the raw Jazz schema.`,136 );137 }138 return column;139}140141function validateTemplate(template: AccessTemplate): void {142 if (template.kind === "shared") {143 requireColumn(template.grants, "resourceId", "Uuid", template.resource._table);144 requireColumn(template.grants, "user_id", "Text");145 requireColumn(template.grants, "can_edit", "Boolean");146 }147 if (template.kind === "group") {148 requireColumn(template.members, "groupId", "Uuid", template.groups._table);149 requireColumn(template.members, "user_id", "Text");150 requireColumn(template.members, "role", "Text");151 requireColumn(template.members, "can_create", "Boolean");152 requireColumn(template.members, "can_edit_any", "Boolean");153 requireColumn(template.members, "can_manage", "Boolean");154 for (const resource of template.resources) {155 requireColumn(resource, template.groupId, "Uuid", template.groups._table);156 }157 }158}159160/** Minimal Jazz rule builder exposed to raw access-policy extensions. */161export type RuleBuilder = {162 where(input: unknown): unknown;163 always(): unknown;164};165/** Read and mutation policy builders for one declared table. */166export type TablePolicy = {167 allowRead: RuleBuilder;168 allowInsert: RuleBuilder;169 allowUpdate: RuleBuilder;170 allowDelete: RuleBuilder;171 exists: { where(input: unknown): unknown };172};173/** Raw Jazz policy-builder context exposed to advanced policy extensions. */174export type RawAccessPolicyContext = {175 policy: Record<string, TablePolicy>;176 session: { user_id: unknown };177 anyOf(conditions: readonly unknown[]): unknown;178 allOf(conditions: readonly unknown[]): unknown;179 allowedTo: {180 update(fkColumn: string): unknown;181 };182};183184/** Callback for app-specific rules that do not fit the built-in templates. */185export type RawAccessPolicyExtension = (context: RawAccessPolicyContext) => void;186187/**188 * Compiles the three narrow templates through Jazz's own policy builder. The189 * optional callback is the raw Jazz-policy escape hatch for app-specific rules.190 *191 * @example192 * ```ts193 * import { defineAccessPolicies, groupAccess, sharedAccess } from "@nzip/lofi/access";194 * import { app } from "./schema.ts";195 *196 * export default defineAccessPolicies(app, [197 * sharedAccess({ resource: app.notes, grants: app.noteGrants }),198 * groupAccess({199 * groups: app.workspaces,200 * members: app.workspaceMembers,201 * resources: app.documents,202 * groupId: "workspaceId",203 * }),204 * ]);205 * ```206 *207 * @param app The raw Jazz app returned by `s.defineApp`.208 * @param templates At least one {@link privateAccess}, {@link sharedAccess}, or209 * {@link groupAccess} template; every table needs a policy.210 * @param raw Optional raw-policy callback for app-specific rules beyond the templates.211 * @returns Compiled permissions suitable as the app's default policy export.212 */213export function defineAccessPolicies<TApp extends object>(214 app: TApp,215 templates: readonly AccessTemplate[],216 raw?: RawAccessPolicyExtension,217): CompiledPermissions {218 if (templates.length === 0) {219 throw new AccessError("configuration", "defineAccessPolicies requires at least one template.");220 }221 for (const template of templates) validateTemplate(template);222223 return s.definePermissions(app, (jazzContext) => {224 const { policy, session, anyOf, allOf, allowedTo } =225 jazzContext as unknown as RawAccessPolicyContext;226 for (const template of templates) {227 if (template.kind === "private") {228 const resource = policy[template.resource._table];229 resource.allowInsert.always();230 resource.allowRead.where({ $createdBy: session.user_id });231 resource.allowUpdate.where({ $createdBy: session.user_id });232 resource.allowDelete.where({ $createdBy: session.user_id });233 continue;234 }235 if (template.kind === "shared") {236 const resource = policy[template.resource._table];237 const grants = policy[template.grants._table];238 const ownerOf = (resourceId: unknown) =>239 resource.exists.where({ id: resourceId, $createdBy: session.user_id });240 resource.allowInsert.always();241 resource.allowRead.where((row: Record<string, unknown>) =>242 anyOf([243 { $createdBy: session.user_id },244 grants.exists.where({ resourceId: row.id, user_id: session.user_id }),245 ])246 );247 resource.allowUpdate.where((row: Record<string, unknown>) =>248 anyOf([249 { $createdBy: session.user_id },250 grants.exists.where({251 resourceId: row.id,252 user_id: session.user_id,253 can_edit: true,254 }),255 ])256 );257 resource.allowDelete.where({ $createdBy: session.user_id });258 grants.allowRead.where((grant: Record<string, unknown>) =>259 anyOf([{ user_id: session.user_id }, ownerOf(grant.resourceId)])260 );261 grants.allowInsert.where((grant: Record<string, unknown>) => ownerOf(grant.resourceId));262 grants.allowUpdate.where((grant: Record<string, unknown>) => ownerOf(grant.resourceId));263 grants.allowDelete.where((grant: Record<string, unknown>) => ownerOf(grant.resourceId));264 continue;265 }266267 const groups = policy[template.groups._table];268 const members = policy[template.members._table];269 const isMember = (groupId: unknown) =>270 members.exists.where({ groupId, user_id: session.user_id });271 const hasCapability = (groupId: unknown, capability: string) =>272 members.exists.where({ groupId, user_id: session.user_id, [capability]: true });273 const isAdmin = (groupId: unknown) => hasCapability(groupId, "can_manage");274 // Group-creator authority is PERMANENT by design: the creator can always275 // update the group row, and — because membership management derives from276 // group-update via `allowedTo.update` — can always restore their own277 // admin membership after demotion or removal. This is a documented trust278 // property of the template, not an oversight: scoping creator authority279 // to a bootstrap window ("only while no admin membership exists")280 // requires a negated existence condition, and the pinned Jazz alpha.53281 // policy engine silently drops Not around Exists/ExistsRel — the window282 // cannot be enforced at the policy boundary. The engine canary in283 // access_security_test.ts fails when an upgrade fixes negation; implement284 // the window then. See docs/decisions/group-creator-authority-alpha53.md.285 //286 // Direct creator delete below grants no additional authority (a creator287 // can always self-bootstrap an admin membership and delete transitively);288 // it exists so group creation can roll back when the first admin insert289 // fails, instead of stranding an undeletable orphan row.290 groups.allowRead.where((group: Record<string, unknown>) =>291 anyOf([isMember(group.id), { $createdBy: session.user_id }])292 );293 groups.allowInsert.always();294 groups.allowUpdate.where((group: Record<string, unknown>) =>295 anyOf([{ $createdBy: session.user_id }, isAdmin(group.id)])296 );297 groups.allowDelete.where((group: Record<string, unknown>) =>298 anyOf([{ $createdBy: session.user_id }, isAdmin(group.id)])299 );300 members.allowRead.where(301 anyOf([302 { user_id: session.user_id },303 allowedTo.update("groupId"),304 ]),305 );306 members.allowInsert.where(allowedTo.update("groupId"));307 members.allowUpdate.where(allowedTo.update("groupId"));308 members.allowDelete.where(309 anyOf([allowedTo.update("groupId"), { user_id: session.user_id }]),310 );311 for (const table of template.resources) {312 const resource = policy[table._table];313 resource.allowRead.where((row: Record<string, unknown>) => isMember(row[template.groupId]));314 resource.allowInsert.where((row: Record<string, unknown>) =>315 hasCapability(row[template.groupId], "can_create")316 );317 resource.allowUpdate.where((row: Record<string, unknown>) =>318 anyOf([319 hasCapability(row[template.groupId], "can_edit_any"),320 allOf([321 { $createdBy: session.user_id },322 hasCapability(row[template.groupId], "can_create"),323 ]),324 ])325 );326 resource.allowDelete.where((row: Record<string, unknown>) =>327 anyOf([328 hasCapability(row[template.groupId], "can_edit_any"),329 allOf([330 { $createdBy: session.user_id },331 hasCapability(row[template.groupId], "can_create"),332 ]),333 ])334 );335 }336 }337 raw?.({ policy, session, anyOf, allOf, allowedTo });338 });339}