@wtfalch/authz-store 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +335 -5
  2. package/dist/activations.d.ts +60 -0
  3. package/dist/activations.js +516 -0
  4. package/dist/alerts.d.ts +86 -0
  5. package/dist/alerts.js +132 -0
  6. package/dist/assignments.d.ts +71 -0
  7. package/dist/assignments.js +103 -0
  8. package/dist/audit.d.ts +122 -0
  9. package/dist/audit.js +194 -0
  10. package/dist/binding.d.ts +17 -0
  11. package/dist/binding.js +8 -0
  12. package/dist/boot.d.ts +54 -0
  13. package/dist/boot.js +143 -0
  14. package/dist/bootstrap.d.ts +28 -0
  15. package/dist/bootstrap.js +83 -0
  16. package/dist/break-glass.d.ts +61 -0
  17. package/dist/break-glass.js +247 -0
  18. package/dist/credentials.d.ts +94 -0
  19. package/dist/credentials.js +230 -0
  20. package/dist/denial.d.ts +9 -0
  21. package/dist/denial.js +49 -0
  22. package/dist/erase.d.ts +58 -0
  23. package/dist/erase.js +108 -0
  24. package/dist/events.d.ts +77 -0
  25. package/dist/events.js +107 -0
  26. package/dist/export.d.ts +161 -0
  27. package/dist/export.js +293 -0
  28. package/dist/index.d.ts +33 -1
  29. package/dist/index.js +33 -1
  30. package/dist/install-owner.d.ts +25 -0
  31. package/dist/install-owner.js +159 -0
  32. package/dist/invitations.d.ts +160 -0
  33. package/dist/invitations.js +685 -0
  34. package/dist/membership-rows.d.ts +206 -0
  35. package/dist/membership-rows.js +271 -0
  36. package/dist/memberships.d.ts +87 -0
  37. package/dist/memberships.js +272 -0
  38. package/dist/migrate.js +15 -4
  39. package/dist/nesting.d.ts +124 -0
  40. package/dist/nesting.js +515 -0
  41. package/dist/owners.d.ts +6 -1
  42. package/dist/owners.js +7 -3
  43. package/dist/person-records.d.ts +186 -0
  44. package/dist/person-records.js +263 -0
  45. package/dist/platform.d.ts +20 -0
  46. package/dist/platform.js +65 -0
  47. package/dist/policy-access.d.ts +260 -0
  48. package/dist/policy-access.js +348 -0
  49. package/dist/policy-resources.d.ts +5 -0
  50. package/dist/policy-resources.js +46 -0
  51. package/dist/policy-schema.d.ts +445 -0
  52. package/dist/policy-schema.js +63 -0
  53. package/dist/policy.d.ts +64 -0
  54. package/dist/policy.js +65 -0
  55. package/dist/propagate.d.ts +43 -0
  56. package/dist/propagate.js +47 -0
  57. package/dist/reconcile.d.ts +78 -0
  58. package/dist/reconcile.js +94 -0
  59. package/dist/resource-access.d.ts +344 -0
  60. package/dist/resource-access.js +656 -0
  61. package/dist/role-keys.d.ts +9 -0
  62. package/dist/role-keys.js +9 -0
  63. package/dist/roles.d.ts +36 -0
  64. package/dist/roles.js +191 -0
  65. package/dist/schema.d.ts +18 -1
  66. package/dist/schema.js +8 -1
  67. package/dist/startup.d.ts +57 -0
  68. package/dist/startup.js +113 -0
  69. package/dist/tenants.d.ts +213 -0
  70. package/dist/tenants.js +808 -0
  71. package/dist/tree-writes.d.ts +65 -0
  72. package/dist/tree-writes.js +201 -0
  73. package/dist/tree.d.ts +272 -0
  74. package/dist/tree.js +565 -0
  75. package/dist/types.d.ts +87 -0
  76. package/dist/types.js +15 -0
  77. package/migrations/0003_product_tenant_kind.sql +14 -0
  78. package/migrations/0004_credential_keys_issued_id.sql +33 -0
  79. package/migrations/0005_activations.sql +71 -0
  80. package/migrations/0006_erase_person.sql +134 -0
  81. package/package.json +9 -4
@@ -0,0 +1,33 @@
1
+ -- authz#95 (decision 2), wave 7: mint and revoke credentials through
2
+ -- @wtfalch/keys/issued instead of this package's own sha256 `wtfk_...`
3
+ -- scheme (credential-secret.ts stays, for rows minted before this).
4
+ --
5
+ -- `secret_hash` is loosened, not dropped: a credential minted from here on
6
+ -- carries no hash of its own (its secret is checked against the
7
+ -- Worker-signed `keys_issued_credentials` row `@wtfalch/keys` owns, not
8
+ -- against a column on this table), and the old rows keep the column and
9
+ -- their history rather than losing it outright. NOT NULL DROP is additive
10
+ -- under a rolling deploy: an old container still inserts a hash, a new one
11
+ -- inserts none, and neither reads the other's row shape.
12
+ --
13
+ -- `keys_issued_id` is the matching `keys_issued_credentials.id`, so
14
+ -- `revokeCredential` can revoke a credential there too, not only mark this
15
+ -- package's own row revoked. Null on a pre-cutover row, which `revoked_at`
16
+ -- on this same table already refuses regardless of what the (now
17
+ -- unreachable, clean-cut) old secret scheme would have said.
18
+ alter table credentials alter column secret_hash drop not null;
19
+ --> statement-breakpoint
20
+ alter table credentials add column if not exists keys_issued_id text;
21
+ --> statement-breakpoint
22
+
23
+ -- `credentials_secret_prefix_check` (0001_baseline.sql) bounded
24
+ -- `secret_prefix` to 16 characters, sized for the old scheme's 8 raw hex
25
+ -- characters. A `@wtfalch/keys/issued` `keyPrefix` is a host's own split-
26
+ -- secret prefix plus an 8-character public id -- longer than 16 already, and
27
+ -- longer still if a host's prefix grows -- so the bound widens to the same
28
+ -- 64 `@wtfalch/keys` itself allows on `keys_issued_credentials.key_prefix`.
29
+ alter table credentials drop constraint if exists credentials_secret_prefix_check;
30
+ --> statement-breakpoint
31
+ alter table credentials
32
+ add constraint credentials_secret_prefix_check
33
+ check (secret_prefix is null or length(secret_prefix) between 1 and 64);
@@ -0,0 +1,71 @@
1
+ -- ADR 0016 ("No standing authority"): storage for time-boxed permission
2
+ -- activations, ported from Boule's drizzle/0014_activations.sql (authz#83
3
+ -- wave 11b). authz_roles.updated_by and the widened authz_assignments
4
+ -- source check (that file's other two changes, plus the six activation.*
5
+ -- names in authz_events_action_check from drizzle/0020_activation_events.sql)
6
+ -- are already in 0001_baseline.sql, so only the two new tables land here.
7
+ --
8
+ -- No FK from principal_id/approver_id to a host's own person table
9
+ -- (`profiles` in Boule): the store never imports a host table, the same as
10
+ -- 0001_baseline.sql's break_glass_sessions.operator_id, left to each host.
11
+ -- Boule's client-side `\if`/`\gset` guard (for its pre/post authz-v2-cutover
12
+ -- databases) does not apply here: every store database has authz_roles and
13
+ -- authz_assignments already, from 0001_baseline.sql, before this file runs.
14
+
15
+ CREATE TABLE "authz_activations" (
16
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
17
+ "tenant_id" uuid NOT NULL,
18
+ "application_id" text NOT NULL,
19
+ "platform_id" text NOT NULL,
20
+ "principal_id" text NOT NULL,
21
+ "principal_class" text NOT NULL,
22
+ "permissions" text[] NOT NULL,
23
+ "reason" text NOT NULL,
24
+ "reference" text NOT NULL,
25
+ "status" text NOT NULL DEFAULT 'pending',
26
+ "requested_at" timestamp with time zone NOT NULL DEFAULT now(),
27
+ "starts_at" timestamp with time zone NOT NULL,
28
+ "expires_at" timestamp with time zone NOT NULL,
29
+ "role_id" uuid NOT NULL,
30
+ "ended_at" timestamp with time zone,
31
+ "ended_by" text,
32
+ CONSTRAINT "authz_activations_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT,
33
+ -- The non-primary authz_roles row minted with exactly the requested
34
+ -- entries, the same indirection break_glass_sessions.role_id uses.
35
+ CONSTRAINT "authz_activations_role_fk" FOREIGN KEY ("tenant_id", "role_id") REFERENCES "authz_roles"("tenant_id", "id"),
36
+ CONSTRAINT "authz_activations_principal_class_check" CHECK ("principal_class" = 'human'),
37
+ CONSTRAINT "authz_activations_permissions_check" CHECK (cardinality("permissions") BETWEEN 1 AND 100),
38
+ CONSTRAINT "authz_activations_reason_check" CHECK (length("reason") BETWEEN 1 AND 512),
39
+ CONSTRAINT "authz_activations_reference_check" CHECK (length("reference") BETWEEN 1 AND 512),
40
+ CONSTRAINT "authz_activations_status_check" CHECK ("status" IN ('pending', 'active', 'denied', 'expired', 'revoked')),
41
+ CONSTRAINT "authz_activations_window_check" CHECK ("expires_at" > "starts_at"),
42
+ CONSTRAINT "authz_activations_ended_check" CHECK ("ended_at" IS NULL OR "ended_at" >= "requested_at")
43
+ );
44
+ --> statement-breakpoint
45
+ -- "does this principal already have an open request", and an approver's own
46
+ -- queue of what is waiting, both scoped by tenant the same way bg_active_idx
47
+ -- and bg_tenant_idx scope break_glass_sessions.
48
+ CREATE INDEX "authz_activations_pending_idx" ON "authz_activations" ("tenant_id", "principal_id") WHERE "status" = 'pending';
49
+ --> statement-breakpoint
50
+ CREATE INDEX "authz_activations_tenant_idx" ON "authz_activations" ("tenant_id", "requested_at" DESC);
51
+ --> statement-breakpoint
52
+ CREATE TABLE "authz_activation_approvals" (
53
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
54
+ "activation_id" uuid NOT NULL,
55
+ "approver_id" text NOT NULL,
56
+ "approver_class" text NOT NULL,
57
+ "decision" text NOT NULL,
58
+ "reason" text,
59
+ "decided_at" timestamp with time zone NOT NULL DEFAULT now(),
60
+ CONSTRAINT "authz_activation_approvals_activation_id_fkey" FOREIGN KEY ("activation_id") REFERENCES "authz_activations"("id") ON DELETE RESTRICT,
61
+ CONSTRAINT "authz_activation_approvals_approver_class_check" CHECK ("approver_class" = 'human'),
62
+ CONSTRAINT "authz_activation_approvals_decision_check" CHECK ("decision" IN ('approved', 'denied')),
63
+ CONSTRAINT "authz_activation_approvals_reason_check" CHECK ("reason" IS NULL OR length("reason") <= 512)
64
+ );
65
+ --> statement-breakpoint
66
+ -- Every decision recorded against one activation, most recent first -- what
67
+ -- approve/selfApprove reads to count Aged-and-Independent approvers and to
68
+ -- refuse a same-principal approval.
69
+ CREATE INDEX "authz_activation_approvals_activation_idx" ON "authz_activation_approvals" ("activation_id", "decided_at" DESC);
70
+ --> statement-breakpoint
71
+ CREATE INDEX "authz_activation_approvals_approver_idx" ON "authz_activation_approvals" ("approver_id");
@@ -0,0 +1,134 @@
1
+ -- Erasure (D9, X6 M3, authz#83 wave 11d): the one sanctioned write to
2
+ -- `authz_events`, plus the store's own scrub of `invitations.email`.
3
+ --
4
+ -- Boule's `drizzle/0008_erasure_and_boot.sql` also updated `profiles` (a
5
+ -- host table) and called `reporting_erase_person` (the host's reporting
6
+ -- package's own function); neither belongs to this migration. Both move to
7
+ -- the `ErasureHost` port `src/erase.ts` takes instead, so this function
8
+ -- touches only what the store owns: `authz_events` and `invitations`.
9
+ --
10
+ -- Additive, per every other file in this directory: never edit this file
11
+ -- once shipped, a change is a new numbered one.
12
+
13
+ -- ------------------------------------------------------- erase_person(...)
14
+ -- The single sanctioned write to `authz_events` (D9, M3).
15
+ --
16
+ -- Two walls stand between the running app and its own audit log, and this
17
+ -- function is the one door through both. 0001's grants give the runtime
18
+ -- role no UPDATE on `authz_events` at all, and 0001's `authz_events_guard`
19
+ -- trigger refuses any UPDATE on that table touching a column outside
20
+ -- `actor_display`, `before`, `after` and `erased_at`, and refuses DELETE and
21
+ -- TRUNCATE outright. A `SECURITY DEFINER` function owned by the migration
22
+ -- role runs with that role's privileges, so it passes the first wall, and it
23
+ -- is written to touch only those four columns (plus `invitations.email`, a
24
+ -- different table the same trigger does not guard), so it passes the second
25
+ -- rather than going around it. The trigger stays the backstop: if this
26
+ -- function is ever edited to touch a fifth `authz_events` column, the
27
+ -- trigger refuses it and the erasure fails loudly.
28
+ --
29
+ -- What it does, and what it deliberately does not. `actor_id` stays, so
30
+ -- "someone with this id did this" survives every row; `actor_display`
31
+ -- becomes a stable pseudonym derived from the id, so "who that was" does
32
+ -- not. Row counts do not change: nothing is deleted, and an erased row is
33
+ -- still an ordered part of the trail. `before` and `after` are rewritten
34
+ -- wholesale rather than field by field, because they are free-form JSON
35
+ -- written by many call sites and a list of personal keys to scrub would be
36
+ -- a list to keep in step forever; what a row meant is in `action`,
37
+ -- `target_*` and `reason`, none of which this touches.
38
+ --
39
+ -- `search_path` is pinned. A SECURITY DEFINER function that resolves an
40
+ -- unqualified name through the caller's `search_path` runs whatever they put
41
+ -- in front of it, with the owner's privileges, which is the standard way
42
+ -- this feature becomes a privilege escalation.
43
+ CREATE OR REPLACE FUNCTION "erase_person"("subject" text, "pseudonym" text, "subject_email" text)
44
+ RETURNS integer
45
+ LANGUAGE plpgsql
46
+ SECURITY DEFINER
47
+ SET search_path = pg_catalog, public
48
+ AS $$
49
+ DECLARE
50
+ touched integer;
51
+ BEGIN
52
+ IF "subject" IS NULL OR length("subject") = 0 THEN
53
+ RAISE EXCEPTION 'erase_person: a subject id is required';
54
+ END IF;
55
+ IF "pseudonym" IS NULL OR length("pseudonym") = 0 OR length("pseudonym") > 256 THEN
56
+ RAISE EXCEPTION 'erase_person: a pseudonym of 1 to 256 characters is required';
57
+ END IF;
58
+
59
+ -- Rows this person WROTE, and rows written ABOUT them.
60
+ --
61
+ -- Only the first was ever swept in Boule's own history, and it is the
62
+ -- smaller half. Almost every payload carrying somebody's personal data was
63
+ -- written by somebody else: `invitation.sent` puts the invitee's email in
64
+ -- the inviter's own row, and the invitee never becomes an actor on it.
65
+ -- Erasing by actor id alone therefore leaves the address that prompted the
66
+ -- request sitting in the log, which is the one outcome erasure exists to
67
+ -- prevent. Matching the email inside `before`/`after` catches those rows,
68
+ -- because the email is the only personal field these payloads ever carry:
69
+ -- everything else in them is a role key, an id or a count.
70
+ UPDATE "authz_events"
71
+ SET "actor_display" = CASE
72
+ WHEN "actor_id" = "subject" THEN "pseudonym"
73
+ ELSE "actor_display"
74
+ END,
75
+ "before" = CASE WHEN "before" IS NULL THEN NULL ELSE '{"erased":true}'::jsonb END,
76
+ "after" = CASE WHEN "after" IS NULL THEN NULL ELSE '{"erased":true}'::jsonb END,
77
+ "erased_at" = now()
78
+ WHERE "erased_at" IS NULL
79
+ AND (
80
+ "actor_id" = "subject"
81
+ OR (
82
+ "subject_email" IS NOT NULL
83
+ AND (
84
+ lower("before" ->> 'email') = "subject_email"
85
+ OR lower("after" ->> 'email') = "subject_email"
86
+ )
87
+ )
88
+ );
89
+ GET DIAGNOSTICS touched = ROW_COUNT;
90
+
91
+ -- The invitation rows themselves. `invitations.email` is a plain NOT NULL
92
+ -- column, not a payload, and no other path ever clears it, so an export
93
+ -- taken after an erasure handed the address straight back. Replaced with a
94
+ -- value derived from the same pseudonym: lower case, so the table's own
95
+ -- CHECK still holds, and at `.invalid`, which RFC 2606 reserves precisely
96
+ -- so that nothing can ever route to it.
97
+ --
98
+ -- No `profiles` UPDATE here, unlike Boule's own function: `profiles` is a
99
+ -- host table, out of reach of a package migration. `erasePerson`
100
+ -- (src/erase.ts) calls the host's own `ErasureHost.eraseHostRecords` for
101
+ -- that, after this function returns and before the `person.erased` audit
102
+ -- row is written.
103
+ IF "subject_email" IS NOT NULL THEN
104
+ UPDATE "invitations"
105
+ SET "email" = "pseudonym" || '@invalid'
106
+ WHERE "email" = "subject_email";
107
+ END IF;
108
+
109
+ RETURN touched;
110
+ END
111
+ $$;
112
+ --> statement-breakpoint
113
+
114
+ -- EXECUTE on a definer function is not UPDATE on what it touches, so the
115
+ -- runtime role may be handed this one without gaining any other access to
116
+ -- the table. Revoked from PUBLIC first, since Postgres grants EXECUTE on a
117
+ -- new function to PUBLIC by default.
118
+ REVOKE ALL ON FUNCTION "erase_person"(text, text, text) FROM PUBLIC;
119
+ --> statement-breakpoint
120
+
121
+ -- The store has no runtime-role convention of its own, unlike Boule's fixed
122
+ -- `<database>_rt`, granted unconditionally. A host whose runtime role
123
+ -- happens to be named that way gets EXECUTE for free; a host with no such
124
+ -- role gets nothing granted here and grants EXECUTE itself, rather than
125
+ -- this migration failing outright for naming a role that does not exist.
126
+ DO $$
127
+ DECLARE
128
+ rt text := current_database() || '_rt';
129
+ BEGIN
130
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = rt) THEN
131
+ EXECUTE format('GRANT EXECUTE ON FUNCTION "erase_person"(text, text, text) TO %I', rt);
132
+ END IF;
133
+ END
134
+ $$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/authz-store",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Persistence and lifecycle for @wtfalch/authz: the storage a host would otherwise write itself.",
5
5
  "type": "module",
6
6
  "files": [
@@ -16,8 +16,10 @@
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "peerDependencies": {
19
- "@wtfalch/authz": "^0.15.0",
20
- "drizzle-orm": "^0.39.0"
19
+ "@wtfalch/authz": "^0.16.0",
20
+ "@wtfalch/db": "0.4.0",
21
+ "drizzle-orm": ">=0.39.3 <1.0.0",
22
+ "zod": "^4.1.13"
21
23
  },
22
24
  "engines": {
23
25
  "node": ">=22.0.0"
@@ -29,12 +31,15 @@
29
31
  "devDependencies": {
30
32
  "@electric-sql/pglite": "^0.5.8",
31
33
  "@types/node": "^22",
34
+ "@wtfalch/db": "0.4.0",
35
+ "@wtfalch/keys": "0.4.2",
32
36
  "drizzle-kit": "^0.31.10",
33
37
  "drizzle-orm": "^0.39.0",
34
38
  "fast-check": "^4.9.0",
35
39
  "postgres": "^3.4.5",
36
40
  "typescript": "^5.9.0",
37
- "vitest": "^4.1.6"
41
+ "vitest": "^4.1.6",
42
+ "zod": "^4.5.4"
38
43
  },
39
44
  "scripts": {
40
45
  "build": "rm -rf dist && tsc -p tsconfig.build.json",