clone

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

feat/141-effect-library
lofi / docs / permissions.md
permissions.md 7.0K rendered
1# Permissions23`src/permissions.ts` is an author-owned security boundary. It defines which signed local-first4identity may read or mutate each table; hiding a button in the UI is not a substitute for policy.56> Permissions bound which identities may read or write; they do not hide data from the server that7> enforces them. See [the threat model](threat-model.md) for what the server can see and8> [encrypted columns](data-and-ui.md#encrypted-columns) for content it cannot.910## The starter policy1112The generated task app uses creator-owned rows:1314```ts15import { s } from "@nzip/lofi/schema";16import { app } from "./schema.ts";1718export default s.definePermissions(app, ({ policy, session }) => {19 policy.tasks.allowInsert.always();20 policy.tasks.allowRead.where({ $createdBy: session.user_id });21 policy.tasks.allowUpdate.where({ $createdBy: session.user_id });22 policy.tasks.allowDelete.where({ $createdBy: session.user_id });23});24```2526This means:2728- Any valid app session may create a task.29- A task is readable only by the identity that created it.30- Only that creator may update or delete it.31- Recovering the same identity restores the same authority; opening a new local account does not.3233## Adding a table3435Add all four decisions deliberately for each new table:36371. Who may insert?382. Who may read a row?393. Who may update it?404. Who may delete it?4142Start with creator ownership unless the product has a concrete collaboration model. Do not make a43table broadly readable merely to get a UI prototype working.4445## Changing an existing policy4647Treat a permission change like a data migration:48491. Describe the intended actors and operations in plain language.502. Update `src/permissions.ts`.513. Run `deno task schema:validate`.524. Run the application tests with at least one allowed and one rejected identity case.535. Deploy the schema/permission bundle explicitly with `deno task schema:deploy`.5455Managed sync configuration is required to publish schema and permission changes. Local-only mode is56still useful for UI work, but it does not prove a cloud permission policy was deployed correctly.5758## Choose one narrow access template5960The starter remains private by default. When a resource has a concrete collaboration model,61`@nzip/lofi/access` compiles three narrow templates through Jazz's own policy builder:6263| Template | Who can read | Who can mutate | Sync |64| --------------- | -------------------------------- | --------------------------------------------------- | -------- |65| `privateAccess` | Row creator | Row creator | Optional |66| `sharedAccess` | Creator plus explicit recipients | Creator; recipients with editable grants may update | Required |67| `groupAccess` | Group members | Determined by the fixed group role | Required |6869The schema stays on the lofi schema surface:7071```ts72import { s } from "@nzip/lofi/schema";73import { groupMembershipTable, sharedGrantTable } from "@nzip/lofi/access";7475const schema = {76 notes: s.table({ title: s.string() }),77 noteGrants: sharedGrantTable("notes"),78 workspaces: s.table({ name: s.string() }),79 workspaceMembers: groupMembershipTable("workspaces"),80 documents: s.table({ workspaceId: s.ref("workspaces"), title: s.string() }),81};82export const app = s.defineApp(schema);83```8485Then compose policies:8687```ts88export default defineAccessPolicies(app, [89 sharedAccess({ resource: app.notes, grants: app.noteGrants }),90 groupAccess({91 groups: app.workspaces,92 members: app.workspaceMembers,93 resources: app.documents,94 groupId: "workspaceId",95 }),96]);97```9899The templates do not replace the schema surface. Use `s.table()`, `s.defineApp()`, and100`s.definePermissions()` directly whenever they do not fit. `defineAccessPolicies` also accepts a101final raw-policy callback for a small extension without hiding the underlying policy builder.102103### Hosting shared encrypted columns104105A group whose resources declare [shared encrypted columns](data-and-ui.md#shared-encrypted-columns)106needs two more tables and two policy touches: `sharedFieldDirectoryTable()` compiled by107`sharedFieldAccess({ directory })` — world-readable, self-only writes, since public keys are public108and integrity comes from fingerprint pinning — and `sharedFieldKeyTable("workspaces")` passed as109`fieldKeys` to `groupAccess`, readable by each row's recipient or any member and writable by a110member signing as themselves. Group operations created with the same `fieldKeys`/`directory` tables111then handle the key lifecycle: creation mints the first key, `addMember` delivers held keys (a112member who has not yet published a directory entry reads `pending-key` until113`reconcileSharedFieldKeys` repairs the gap), and `removeMember` rotates so future content is sealed114to the removed member. Policies can never reference the sealed columns themselves — gate on115plaintext columns, and let sealing bound what the server can read.116117## Fixed group roles118119| Role | Read | Create | Edit own | Edit any | Manage members |120| ------------- | ---- | ------ | -------- | -------- | -------------- |121| `reader` | yes | no | no | no | no |122| `contributor` | yes | yes | yes | no | no |123| `writer` | yes | yes | yes | yes | no |124| `admin` | yes | yes | yes | yes | yes |125126The helper stores the role label plus fixed capability bits because the pinned Jazz alpha rejects127role-string comparisons inside relationship policies. Only the four role names are accepted by the128typed operations; custom roles belong in a raw policy callback.129130**The group creator holds a permanent superseat.** Whoever created the group row can always update131it and — because membership management derives from group-update authority — can always restore132their own admin membership, even after other admins demote or remove them. This is a documented133property of the template, not revocable admin membership: the pinned Jazz alpha cannot express134"creator authority only until the first admin exists" (its policy engine drops negated existence135conditions), so do not present creator handover as complete in product UI. The creator can also136delete their own group rows, which is what lets group creation roll back cleanly if bootstrapping137the first admin membership fails. See138[the decision record](decisions/group-creator-authority-alpha53.md).139140Direct shares and group membership use a non-secret `lofi1:<app-id>:<jazz-user-id>` sharing141identity. It is safe to copy for this purpose but is not a directory, invitation, or sign-in token.142The package deliberately does not discover users or deliver invitations.143144See the focused [Shared](examples/shared.md) and [Group](examples/group.md) examples and the145[access API reference](reference/access.md).146147When adding collaboration, test at least:148149- the creator's access;150- an explicitly authorized second identity;151- an unrelated identity;152- access after the product's revoke/remove action;153- offline edits followed by reconnection.