@wtfalch/authz-store 0.2.1 → 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.
- package/README.md +335 -5
- package/dist/activations.d.ts +60 -0
- package/dist/activations.js +516 -0
- package/dist/alerts.d.ts +86 -0
- package/dist/alerts.js +132 -0
- package/dist/assignments.d.ts +71 -0
- package/dist/assignments.js +103 -0
- package/dist/audit.d.ts +122 -0
- package/dist/audit.js +194 -0
- package/dist/binding.d.ts +17 -0
- package/dist/binding.js +8 -0
- package/dist/boot.d.ts +54 -0
- package/dist/boot.js +143 -0
- package/dist/bootstrap.d.ts +28 -0
- package/dist/bootstrap.js +83 -0
- package/dist/break-glass.d.ts +61 -0
- package/dist/break-glass.js +247 -0
- package/dist/credentials.d.ts +94 -0
- package/dist/credentials.js +230 -0
- package/dist/denial.d.ts +9 -0
- package/dist/denial.js +49 -0
- package/dist/erase.d.ts +58 -0
- package/dist/erase.js +108 -0
- package/dist/events.d.ts +77 -0
- package/dist/events.js +107 -0
- package/dist/export.d.ts +161 -0
- package/dist/export.js +293 -0
- package/dist/grants.d.ts +2 -0
- package/dist/index.d.ts +33 -1
- package/dist/index.js +33 -1
- package/dist/install-owner.d.ts +25 -0
- package/dist/install-owner.js +159 -0
- package/dist/invitations.d.ts +160 -0
- package/dist/invitations.js +685 -0
- package/dist/membership-rows.d.ts +206 -0
- package/dist/membership-rows.js +271 -0
- package/dist/memberships.d.ts +87 -0
- package/dist/memberships.js +272 -0
- package/dist/nesting.d.ts +124 -0
- package/dist/nesting.js +515 -0
- package/dist/person-records.d.ts +186 -0
- package/dist/person-records.js +263 -0
- package/dist/platform.d.ts +20 -0
- package/dist/platform.js +65 -0
- package/dist/policy-access.d.ts +260 -0
- package/dist/policy-access.js +348 -0
- package/dist/policy-resources.d.ts +5 -0
- package/dist/policy-resources.js +46 -0
- package/dist/policy-schema.d.ts +449 -0
- package/dist/policy-schema.js +63 -0
- package/dist/policy.d.ts +64 -0
- package/dist/policy.js +65 -0
- package/dist/propagate.d.ts +43 -0
- package/dist/propagate.js +47 -0
- package/dist/reconcile.d.ts +78 -0
- package/dist/reconcile.js +94 -0
- package/dist/resource-access.d.ts +344 -0
- package/dist/resource-access.js +656 -0
- package/dist/role-keys.d.ts +9 -0
- package/dist/role-keys.js +9 -0
- package/dist/roles.d.ts +36 -0
- package/dist/roles.js +191 -0
- package/dist/schema.d.ts +18 -1
- package/dist/schema.js +8 -1
- package/dist/startup.d.ts +57 -0
- package/dist/startup.js +113 -0
- package/dist/tenants.d.ts +213 -0
- package/dist/tenants.js +808 -0
- package/dist/tree-writes.d.ts +65 -0
- package/dist/tree-writes.js +201 -0
- package/dist/tree.d.ts +272 -0
- package/dist/tree.js +565 -0
- package/dist/types.d.ts +87 -0
- package/dist/types.js +15 -0
- package/migrations/0003_product_tenant_kind.sql +14 -0
- package/migrations/0004_credential_keys_issued_id.sql +33 -0
- package/migrations/0005_activations.sql +71 -0
- package/migrations/0006_erase_person.sql +134 -0
- 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.
|
|
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.
|
|
20
|
-
"
|
|
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",
|