@wtfalch/authz-store 0.2.1 → 0.4.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 +393 -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 +135 -0
  9. package/dist/audit.js +231 -0
  10. package/dist/binding.d.ts +26 -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/grants.d.ts +2 -0
  29. package/dist/index.d.ts +34 -1
  30. package/dist/index.js +34 -1
  31. package/dist/install-owner.d.ts +25 -0
  32. package/dist/install-owner.js +159 -0
  33. package/dist/invitations.d.ts +160 -0
  34. package/dist/invitations.js +685 -0
  35. package/dist/membership-rows.d.ts +206 -0
  36. package/dist/membership-rows.js +271 -0
  37. package/dist/memberships.d.ts +87 -0
  38. package/dist/memberships.js +272 -0
  39. package/dist/nesting.d.ts +124 -0
  40. package/dist/nesting.js +515 -0
  41. package/dist/person-records.d.ts +186 -0
  42. package/dist/person-records.js +263 -0
  43. package/dist/platform.d.ts +20 -0
  44. package/dist/platform.js +65 -0
  45. package/dist/policy-access.d.ts +260 -0
  46. package/dist/policy-access.js +357 -0
  47. package/dist/policy-entry.d.ts +11 -0
  48. package/dist/policy-entry.js +10 -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 +449 -0
  52. package/dist/policy-schema.js +63 -0
  53. package/dist/policy.d.ts +71 -0
  54. package/dist/policy.js +78 -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 +143 -0
  81. package/package.json +13 -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,143 @@
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
+ -- 0.4.0 note: this function was originally named `erase_person`, the same
14
+ -- name and signature as each host's own erasure function (the one that also
15
+ -- scrubs `profiles`). `migrateStore` replaces a same-named function outright,
16
+ -- so applying this migration silently dropped a host's own `profiles` scrub.
17
+ -- Renamed here to `authz_erase_person` before any persistent database ever
18
+ -- applied it: 0.3.0 published an hour before this fix, and no host had run
19
+ -- this migration against a real database yet, so this is a same-file rename,
20
+ -- not a new numbered migration.
21
+
22
+ -- ----------------------------------------------- authz_erase_person(...)
23
+ -- The single sanctioned write to `authz_events` (D9, M3).
24
+ --
25
+ -- Two walls stand between the running app and its own audit log, and this
26
+ -- function is the one door through both. 0001's grants give the runtime
27
+ -- role no UPDATE on `authz_events` at all, and 0001's `authz_events_guard`
28
+ -- trigger refuses any UPDATE on that table touching a column outside
29
+ -- `actor_display`, `before`, `after` and `erased_at`, and refuses DELETE and
30
+ -- TRUNCATE outright. A `SECURITY DEFINER` function owned by the migration
31
+ -- role runs with that role's privileges, so it passes the first wall, and it
32
+ -- is written to touch only those four columns (plus `invitations.email`, a
33
+ -- different table the same trigger does not guard), so it passes the second
34
+ -- rather than going around it. The trigger stays the backstop: if this
35
+ -- function is ever edited to touch a fifth `authz_events` column, the
36
+ -- trigger refuses it and the erasure fails loudly.
37
+ --
38
+ -- What it does, and what it deliberately does not. `actor_id` stays, so
39
+ -- "someone with this id did this" survives every row; `actor_display`
40
+ -- becomes a stable pseudonym derived from the id, so "who that was" does
41
+ -- not. Row counts do not change: nothing is deleted, and an erased row is
42
+ -- still an ordered part of the trail. `before` and `after` are rewritten
43
+ -- wholesale rather than field by field, because they are free-form JSON
44
+ -- written by many call sites and a list of personal keys to scrub would be
45
+ -- a list to keep in step forever; what a row meant is in `action`,
46
+ -- `target_*` and `reason`, none of which this touches.
47
+ --
48
+ -- `search_path` is pinned. A SECURITY DEFINER function that resolves an
49
+ -- unqualified name through the caller's `search_path` runs whatever they put
50
+ -- in front of it, with the owner's privileges, which is the standard way
51
+ -- this feature becomes a privilege escalation.
52
+ CREATE OR REPLACE FUNCTION "authz_erase_person"("subject" text, "pseudonym" text, "subject_email" text)
53
+ RETURNS integer
54
+ LANGUAGE plpgsql
55
+ SECURITY DEFINER
56
+ SET search_path = pg_catalog, public
57
+ AS $$
58
+ DECLARE
59
+ touched integer;
60
+ BEGIN
61
+ IF "subject" IS NULL OR length("subject") = 0 THEN
62
+ RAISE EXCEPTION 'authz_erase_person: a subject id is required';
63
+ END IF;
64
+ IF "pseudonym" IS NULL OR length("pseudonym") = 0 OR length("pseudonym") > 256 THEN
65
+ RAISE EXCEPTION 'authz_erase_person: a pseudonym of 1 to 256 characters is required';
66
+ END IF;
67
+
68
+ -- Rows this person WROTE, and rows written ABOUT them.
69
+ --
70
+ -- Only the first was ever swept in Boule's own history, and it is the
71
+ -- smaller half. Almost every payload carrying somebody's personal data was
72
+ -- written by somebody else: `invitation.sent` puts the invitee's email in
73
+ -- the inviter's own row, and the invitee never becomes an actor on it.
74
+ -- Erasing by actor id alone therefore leaves the address that prompted the
75
+ -- request sitting in the log, which is the one outcome erasure exists to
76
+ -- prevent. Matching the email inside `before`/`after` catches those rows,
77
+ -- because the email is the only personal field these payloads ever carry:
78
+ -- everything else in them is a role key, an id or a count.
79
+ UPDATE "authz_events"
80
+ SET "actor_display" = CASE
81
+ WHEN "actor_id" = "subject" THEN "pseudonym"
82
+ ELSE "actor_display"
83
+ END,
84
+ "before" = CASE WHEN "before" IS NULL THEN NULL ELSE '{"erased":true}'::jsonb END,
85
+ "after" = CASE WHEN "after" IS NULL THEN NULL ELSE '{"erased":true}'::jsonb END,
86
+ "erased_at" = now()
87
+ WHERE "erased_at" IS NULL
88
+ AND (
89
+ "actor_id" = "subject"
90
+ OR (
91
+ "subject_email" IS NOT NULL
92
+ AND (
93
+ lower("before" ->> 'email') = "subject_email"
94
+ OR lower("after" ->> 'email') = "subject_email"
95
+ )
96
+ )
97
+ );
98
+ GET DIAGNOSTICS touched = ROW_COUNT;
99
+
100
+ -- The invitation rows themselves. `invitations.email` is a plain NOT NULL
101
+ -- column, not a payload, and no other path ever clears it, so an export
102
+ -- taken after an erasure handed the address straight back. Replaced with a
103
+ -- value derived from the same pseudonym: lower case, so the table's own
104
+ -- CHECK still holds, and at `.invalid`, which RFC 2606 reserves precisely
105
+ -- so that nothing can ever route to it.
106
+ --
107
+ -- No `profiles` UPDATE here, unlike Boule's own function: `profiles` is a
108
+ -- host table, out of reach of a package migration. `erasePerson`
109
+ -- (src/erase.ts) calls the host's own `ErasureHost.eraseHostRecords` for
110
+ -- that, after this function returns and before the `person.erased` audit
111
+ -- row is written.
112
+ IF "subject_email" IS NOT NULL THEN
113
+ UPDATE "invitations"
114
+ SET "email" = "pseudonym" || '@invalid'
115
+ WHERE "email" = "subject_email";
116
+ END IF;
117
+
118
+ RETURN touched;
119
+ END
120
+ $$;
121
+ --> statement-breakpoint
122
+
123
+ -- EXECUTE on a definer function is not UPDATE on what it touches, so the
124
+ -- runtime role may be handed this one without gaining any other access to
125
+ -- the table. Revoked from PUBLIC first, since Postgres grants EXECUTE on a
126
+ -- new function to PUBLIC by default.
127
+ REVOKE ALL ON FUNCTION "authz_erase_person"(text, text, text) FROM PUBLIC;
128
+ --> statement-breakpoint
129
+
130
+ -- The store has no runtime-role convention of its own, unlike Boule's fixed
131
+ -- `<database>_rt`, granted unconditionally. A host whose runtime role
132
+ -- happens to be named that way gets EXECUTE for free; a host with no such
133
+ -- role gets nothing granted here and grants EXECUTE itself, rather than
134
+ -- this migration failing outright for naming a role that does not exist.
135
+ DO $$
136
+ DECLARE
137
+ rt text := current_database() || '_rt';
138
+ BEGIN
139
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = rt) THEN
140
+ EXECUTE format('GRANT EXECUTE ON FUNCTION "authz_erase_person"(text, text, text) TO %I', rt);
141
+ END IF;
142
+ END
143
+ $$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/authz-store",
3
- "version": "0.2.1",
3
+ "version": "0.4.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": [
@@ -13,11 +13,17 @@
13
13
  "types": "./dist/index.d.ts",
14
14
  "default": "./dist/index.js"
15
15
  },
16
+ "./policy": {
17
+ "types": "./dist/policy-entry.d.ts",
18
+ "default": "./dist/policy-entry.js"
19
+ },
16
20
  "./package.json": "./package.json"
17
21
  },
18
22
  "peerDependencies": {
19
- "@wtfalch/authz": "^0.15.0",
20
- "drizzle-orm": ">=0.39.0 <1.0.0"
23
+ "@wtfalch/authz": "^0.16.0",
24
+ "@wtfalch/db": "0.4.0",
25
+ "drizzle-orm": ">=0.39.3 <1.0.0",
26
+ "zod": "^4.1.13"
21
27
  },
22
28
  "engines": {
23
29
  "node": ">=22.0.0"
@@ -29,12 +35,15 @@
29
35
  "devDependencies": {
30
36
  "@electric-sql/pglite": "^0.5.8",
31
37
  "@types/node": "^22",
38
+ "@wtfalch/db": "0.4.0",
39
+ "@wtfalch/keys": "0.4.2",
32
40
  "drizzle-kit": "^0.31.10",
33
41
  "drizzle-orm": "^0.39.0",
34
42
  "fast-check": "^4.9.0",
35
43
  "postgres": "^3.4.5",
36
44
  "typescript": "^5.9.0",
37
- "vitest": "^4.1.6"
45
+ "vitest": "^4.1.6",
46
+ "zod": "^4.5.4"
38
47
  },
39
48
  "scripts": {
40
49
  "build": "rm -rf dist && tsc -p tsconfig.build.json",