@wtfalch/foundry 0.6.0 → 0.8.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/dist/bootstrap/bootstrap.d.ts +12 -8
- package/dist/bootstrap/bootstrap.js +4 -12
- package/dist/bootstrap/declare.d.ts +3 -1
- package/dist/bootstrap/declare.js +6 -2
- package/dist/bootstrap/versions.d.ts +25 -0
- package/dist/bootstrap/versions.js +43 -0
- package/dist/calls-sfu-deploy-support.d.ts +1 -1
- package/dist/calls-sfu-deploy-support.js +1 -1
- package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
- package/dist/calls-sfu-ssh-adapter.js +1 -1
- package/dist/calls-sfu.d.ts +8 -6
- package/dist/calls-sfu.js +9 -6
- package/dist/index.d.ts +3 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0022_chat_enabled.sql +6 -0
- package/dist/migrations/0023_hosting_app_env.sql +8 -0
- package/dist/migrations/0024_audit_events.sql +211 -0
- package/dist/migrations/0025_audit_display.sql +147 -0
- package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
- package/dist/migrations/0027_audit_chain.sql +170 -0
- package/dist/migrations/0028_audit_rls.sql +111 -0
- package/dist/migrations-dir.d.ts +9 -0
- package/dist/migrations-dir.js +10 -0
- package/dist/org.d.ts +24 -0
- package/dist/org.js +13 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +23 -0
- package/dist/plan/calls-matrix.d.ts +4 -2
- package/dist/plan/calls-matrix.js +5 -2
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +5 -3
- package/dist/plan/diff.d.ts +18 -3
- package/dist/plan/diff.js +369 -42
- package/dist/plan/executor.d.ts +378 -44
- package/dist/plan/executor.js +816 -57
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/login-host.d.ts +88 -0
- package/dist/plan/login-host.js +73 -0
- package/dist/plan/plan-apply.d.ts +23 -8
- package/dist/plan/plan-apply.js +30 -10
- package/dist/plan/platform-diff.d.ts +102 -2
- package/dist/plan/platform-diff.js +232 -1
- package/dist/plan/platform-providers.d.ts +37 -2
- package/dist/plan/platform-providers.js +223 -36
- package/dist/plan/providers-readers.js +44 -1
- package/dist/plan.d.ts +43 -0
- package/dist/plan.js +16 -1
- package/dist/store/audit.d.ts +36 -4
- package/dist/store/audit.js +110 -29
- package/dist/store/postgres.d.ts +14 -0
- package/dist/store/postgres.js +17 -9
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +7 -0
- package/package.json +10 -5
- package/versions.json +8 -0
- package/dist/migrate.d.ts +0 -38
- package/dist/migrate.js +0 -47
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
-- Copied verbatim from @wtfalch/audit 0.6.0's own migrations/ (#232), the
|
|
2
|
+
-- same way that package's own audit-migrations CLI copies them into a host's
|
|
3
|
+
-- drizzle/ directory -- foundry has no drizzle/ journal, so this file is
|
|
4
|
+
-- that copy, hand-placed at the next free number instead. Never edit the
|
|
5
|
+
-- copied SQL below; a fix ships as a new numbered file, same as upstream.
|
|
6
|
+
--
|
|
7
|
+
-- @wtfalch/audit 0.4.0: what the target and the tenant were CALLED.
|
|
8
|
+
--
|
|
9
|
+
-- Mirrors src/tables.ts. Every statement is idempotent. Applied by the host's
|
|
10
|
+
-- own migrate script after the host copies this file into its drizzle/
|
|
11
|
+
-- directory as the next number (audit-migrations); never edited there.
|
|
12
|
+
--
|
|
13
|
+
-- WHY THE NAME LIVES ON THE ROW
|
|
14
|
+
-- -----------------------------
|
|
15
|
+
-- A reader of the trail wants to know what happened to WHAT. Looking the name
|
|
16
|
+
-- up when the page renders answers that exactly until it matters: the file was
|
|
17
|
+
-- deleted, the organisation was closed, the key was revoked -- and those are
|
|
18
|
+
-- the rows a trail exists for. So the name is written with the row and never
|
|
19
|
+
-- read from anywhere else afterwards.
|
|
20
|
+
--
|
|
21
|
+
-- Both columns are nullable and stay nullable: every row written before this
|
|
22
|
+
-- migration has no name, and a writer that holds only an id is not made to
|
|
23
|
+
-- invent one. A reader shows the id when the name is null.
|
|
24
|
+
--
|
|
25
|
+
-- These are NOT a rename of target_id or tenant_id. The ids stay the identity;
|
|
26
|
+
-- the names are what they were called at the time, and a later rename does not
|
|
27
|
+
-- reach back.
|
|
28
|
+
--
|
|
29
|
+
-- ERASURE
|
|
30
|
+
-- -------
|
|
31
|
+
-- A target can be a person: an invitation's invitee, a membership's holder.
|
|
32
|
+
-- Its name is then personal data sitting in a row that outlives the account,
|
|
33
|
+
-- so audit_erase_person pseudonymises target_display exactly as it already
|
|
34
|
+
-- pseudonymises actor_display: on the rows it already erases, and only where
|
|
35
|
+
-- target_id is the subject. Which rows those are is unchanged on purpose. A
|
|
36
|
+
-- row whose target is a person is expected to carry subject_id, which is what
|
|
37
|
+
-- the match already looks at; a row that sets neither subject_id nor an email
|
|
38
|
+
-- in its payload was already outside erasure's reach before this file, and
|
|
39
|
+
-- widening the match here would erase rows 0001 deliberately did not.
|
|
40
|
+
-- tenant_display names an organisation, not a person, and is left alone; it is
|
|
41
|
+
-- therefore added to the guard's immutable list, so nothing can edit it after
|
|
42
|
+
-- the fact the way a correction would.
|
|
43
|
+
|
|
44
|
+
alter table audit_events add column if not exists target_display text;
|
|
45
|
+
alter table audit_events add column if not exists tenant_display text;
|
|
46
|
+
|
|
47
|
+
do $$ begin
|
|
48
|
+
alter table audit_events
|
|
49
|
+
add constraint audit_events_target_display_check
|
|
50
|
+
check (target_display is null or length(target_display) between 1 and 256);
|
|
51
|
+
exception when duplicate_object then null; end $$;
|
|
52
|
+
|
|
53
|
+
do $$ begin
|
|
54
|
+
alter table audit_events
|
|
55
|
+
add constraint audit_events_tenant_display_check
|
|
56
|
+
check (tenant_display is null or length(tenant_display) between 1 and 256);
|
|
57
|
+
exception when duplicate_object then null; end $$;
|
|
58
|
+
|
|
59
|
+
-- 0001's guard, with tenant_display added to the columns an update may not
|
|
60
|
+
-- change. target_display is absent from the list for the same reason
|
|
61
|
+
-- actor_display is: erasure rewrites it.
|
|
62
|
+
create or replace function audit_events_guard() returns trigger
|
|
63
|
+
language plpgsql as $$
|
|
64
|
+
begin
|
|
65
|
+
if tg_op = 'DELETE' then
|
|
66
|
+
raise exception 'audit_events is append-only: delete refused';
|
|
67
|
+
elsif tg_op = 'TRUNCATE' then
|
|
68
|
+
raise exception 'audit_events is append-only: truncate refused';
|
|
69
|
+
elsif tg_op = 'UPDATE' then
|
|
70
|
+
if new.id is distinct from old.id
|
|
71
|
+
or new.occurred_at is distinct from old.occurred_at
|
|
72
|
+
or new.tenant_id is distinct from old.tenant_id
|
|
73
|
+
or new.tenant_display is distinct from old.tenant_display
|
|
74
|
+
or new.actor_class is distinct from old.actor_class
|
|
75
|
+
or new.actor_id is distinct from old.actor_id
|
|
76
|
+
or new.action is distinct from old.action
|
|
77
|
+
or new.target_type is distinct from old.target_type
|
|
78
|
+
or new.target_id is distinct from old.target_id
|
|
79
|
+
or new.outcome is distinct from old.outcome
|
|
80
|
+
or new.context is distinct from old.context
|
|
81
|
+
or new.session_id is distinct from old.session_id
|
|
82
|
+
or new.reason is distinct from old.reason
|
|
83
|
+
or new.reference is distinct from old.reference
|
|
84
|
+
or new.request_id is distinct from old.request_id
|
|
85
|
+
or new.ip is distinct from old.ip
|
|
86
|
+
or new.user_agent is distinct from old.user_agent
|
|
87
|
+
or new.tenant_visible is distinct from old.tenant_visible
|
|
88
|
+
or new.schema_version is distinct from old.schema_version
|
|
89
|
+
or new.subject_class is distinct from old.subject_class
|
|
90
|
+
or new.subject_id is distinct from old.subject_id
|
|
91
|
+
then
|
|
92
|
+
raise exception 'audit_events is append-only: only actor_display, target_display, before, after and erased_at may change';
|
|
93
|
+
end if;
|
|
94
|
+
end if;
|
|
95
|
+
return new;
|
|
96
|
+
end
|
|
97
|
+
$$;
|
|
98
|
+
|
|
99
|
+
-- 0001's erasure function, extended to the target's name. Everything else is
|
|
100
|
+
-- unchanged: same subject match, same pseudonym bounds, same return.
|
|
101
|
+
create or replace function audit_erase_person(subject text, pseudonym text, subject_email text)
|
|
102
|
+
returns integer
|
|
103
|
+
language plpgsql
|
|
104
|
+
security definer
|
|
105
|
+
set search_path from current
|
|
106
|
+
as $$
|
|
107
|
+
declare
|
|
108
|
+
touched integer;
|
|
109
|
+
begin
|
|
110
|
+
if subject is null or length(subject) = 0 then
|
|
111
|
+
raise exception 'audit_erase_person: a subject id is required';
|
|
112
|
+
end if;
|
|
113
|
+
if pseudonym is null or length(pseudonym) = 0 or length(pseudonym) > 256 then
|
|
114
|
+
raise exception 'audit_erase_person: a pseudonym of 1 to 256 characters is required';
|
|
115
|
+
end if;
|
|
116
|
+
update audit_events
|
|
117
|
+
set actor_display = case when actor_id = subject then pseudonym else actor_display end,
|
|
118
|
+
target_display = case when target_id = subject then pseudonym else target_display end,
|
|
119
|
+
before = case when before is null then null else '{"erased":true}'::jsonb end,
|
|
120
|
+
after = case when after is null then null else '{"erased":true}'::jsonb end,
|
|
121
|
+
erased_at = now()
|
|
122
|
+
where erased_at is null
|
|
123
|
+
and (
|
|
124
|
+
actor_id = subject
|
|
125
|
+
or subject_id = subject
|
|
126
|
+
or (
|
|
127
|
+
subject_email is not null
|
|
128
|
+
and (before::text like '%' || subject_email || '%' or after::text like '%' || subject_email || '%')
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
get diagnostics touched = row_count;
|
|
132
|
+
return touched;
|
|
133
|
+
end
|
|
134
|
+
$$;
|
|
135
|
+
|
|
136
|
+
revoke all on function audit_erase_person(text, text, text) from public;
|
|
137
|
+
|
|
138
|
+
do $$
|
|
139
|
+
declare
|
|
140
|
+
rt text := current_database() || '_rt';
|
|
141
|
+
begin
|
|
142
|
+
if exists (select 1 from pg_roles where rolname = rt) then
|
|
143
|
+
execute format('revoke update, delete, truncate on audit_events from %I', rt);
|
|
144
|
+
execute format('grant execute on function audit_erase_person(text, text, text) to %I', rt);
|
|
145
|
+
end if;
|
|
146
|
+
end
|
|
147
|
+
$$;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
-- Copied verbatim from @wtfalch/audit 0.6.0's own migrations/ (#232), the
|
|
2
|
+
-- same way that package's own audit-migrations CLI copies them into a host's
|
|
3
|
+
-- drizzle/ directory -- foundry has no drizzle/ journal, so this file is
|
|
4
|
+
-- that copy, hand-placed at the next free number instead. Never edit the
|
|
5
|
+
-- copied SQL below; a fix ships as a new numbered file, same as upstream.
|
|
6
|
+
--
|
|
7
|
+
-- @wtfalch/audit: restore audit_erase_person's empty-email guard.
|
|
8
|
+
--
|
|
9
|
+
-- Mirrors src/ledger.ts's erase(). Every statement is idempotent. Applied by
|
|
10
|
+
-- the host's own migrate script after the host copies this file into its
|
|
11
|
+
-- drizzle/ directory as the next number (audit-migrations); never edited
|
|
12
|
+
-- there.
|
|
13
|
+
--
|
|
14
|
+
-- REGRESSION
|
|
15
|
+
-- ----------
|
|
16
|
+
-- 0002_display.sql's audit_erase_person dropped two things 0001_audit.sql
|
|
17
|
+
-- had: the `length(subject_email) > 0` guard and the lower() case-fold on
|
|
18
|
+
-- both the column and the match value. Without the length guard, an empty
|
|
19
|
+
-- string reaches the SQL LIKE match as `like '%%'`, which matches every
|
|
20
|
+
-- row's before/after text -- on every tenant, not just the subject's. This
|
|
21
|
+
-- file cannot edit 0002 (shipped migrations don't change); it re-defines
|
|
22
|
+
-- the function, which `create or replace` makes safe to apply on top.
|
|
23
|
+
--
|
|
24
|
+
-- Everything else about the function is unchanged from 0002: same subject
|
|
25
|
+
-- and subject_id match, same pseudonym bounds, same target_display and
|
|
26
|
+
-- actor_display pseudonymisation, same return.
|
|
27
|
+
|
|
28
|
+
create or replace function audit_erase_person(subject text, pseudonym text, subject_email text)
|
|
29
|
+
returns integer
|
|
30
|
+
language plpgsql
|
|
31
|
+
security definer
|
|
32
|
+
set search_path from current
|
|
33
|
+
as $$
|
|
34
|
+
declare
|
|
35
|
+
touched integer;
|
|
36
|
+
begin
|
|
37
|
+
if subject is null or length(subject) = 0 then
|
|
38
|
+
raise exception 'audit_erase_person: a subject id is required';
|
|
39
|
+
end if;
|
|
40
|
+
if pseudonym is null or length(pseudonym) = 0 or length(pseudonym) > 256 then
|
|
41
|
+
raise exception 'audit_erase_person: a pseudonym of 1 to 256 characters is required';
|
|
42
|
+
end if;
|
|
43
|
+
update audit_events
|
|
44
|
+
set actor_display = case when actor_id = subject then pseudonym else actor_display end,
|
|
45
|
+
target_display = case when target_id = subject then pseudonym else target_display end,
|
|
46
|
+
before = case when before is null then null else '{"erased":true}'::jsonb end,
|
|
47
|
+
after = case when after is null then null else '{"erased":true}'::jsonb end,
|
|
48
|
+
erased_at = now()
|
|
49
|
+
where erased_at is null
|
|
50
|
+
and (
|
|
51
|
+
actor_id = subject
|
|
52
|
+
or subject_id = subject
|
|
53
|
+
or (
|
|
54
|
+
subject_email is not null
|
|
55
|
+
and length(subject_email) > 0
|
|
56
|
+
and (
|
|
57
|
+
lower(coalesce(before::text, '')) like '%' || lower(subject_email) || '%'
|
|
58
|
+
or lower(coalesce(after::text, '')) like '%' || lower(subject_email) || '%'
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
get diagnostics touched = row_count;
|
|
63
|
+
return touched;
|
|
64
|
+
end
|
|
65
|
+
$$;
|
|
66
|
+
|
|
67
|
+
revoke all on function audit_erase_person(text, text, text) from public;
|
|
68
|
+
|
|
69
|
+
do $$
|
|
70
|
+
declare
|
|
71
|
+
rt text := current_database() || '_rt';
|
|
72
|
+
begin
|
|
73
|
+
if exists (select 1 from pg_roles where rolname = rt) then
|
|
74
|
+
execute format('revoke update, delete, truncate on audit_events from %I', rt);
|
|
75
|
+
execute format('grant execute on function audit_erase_person(text, text, text) to %I', rt);
|
|
76
|
+
end if;
|
|
77
|
+
end
|
|
78
|
+
$$;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
-- Copied verbatim from @wtfalch/audit 0.6.0's own migrations/ (#232), the
|
|
2
|
+
-- same way that package's own audit-migrations CLI copies them into a host's
|
|
3
|
+
-- drizzle/ directory -- foundry has no drizzle/ journal, so this file is
|
|
4
|
+
-- that copy, hand-placed at the next free number instead. Never edit the
|
|
5
|
+
-- copied SQL below; a fix ships as a new numbered file, same as upstream.
|
|
6
|
+
--
|
|
7
|
+
-- @wtfalch/audit: hash chaining, opt in.
|
|
8
|
+
--
|
|
9
|
+
-- Mirrors src/chain.ts and src/tables.ts. Every statement is idempotent.
|
|
10
|
+
-- Applied by the host's own migrate script after the host copies this file
|
|
11
|
+
-- into its drizzle/ directory as the next number (audit-migrations); never
|
|
12
|
+
-- edited there.
|
|
13
|
+
--
|
|
14
|
+
-- Nothing here forces chaining. Five nullable columns, computed and
|
|
15
|
+
-- verified entirely in TypeScript (src/chain.ts), the same design
|
|
16
|
+
-- `@wtfalch/authz`'s audit-chain.ts already shipped: no pgcrypto, no
|
|
17
|
+
-- trigger, because PGlite -- this package's own default test database --
|
|
18
|
+
-- has no pgcrypto, and a trigger cannot see the previous row's hash without
|
|
19
|
+
-- a race between concurrent inserts that only a lock (below) closes.
|
|
20
|
+
--
|
|
21
|
+
-- prev_hash -- the previous row's row_hash, in whatever order rows
|
|
22
|
+
-- were sealed at write time. null for the chain's first
|
|
23
|
+
-- row, and forever on a row nothing ever sealed.
|
|
24
|
+
-- row_hash -- sha256 over this row's own fields (chain.ts's
|
|
25
|
+
-- ROW_HASH_KEYS) plus prev_hash and content_hash. Never
|
|
26
|
+
-- changes once set; the append-only guard below still
|
|
27
|
+
-- refuses to.
|
|
28
|
+
-- content_hash -- sha256 over the row's erasable content (actor_display,
|
|
29
|
+
-- target_display, before, after), salted.
|
|
30
|
+
-- content_salt -- the salt content_hash was taken over. Set at seal
|
|
31
|
+
-- time, nulled by erasure: once it is gone nobody --
|
|
32
|
+
-- including this package -- can reproduce content_hash
|
|
33
|
+
-- from a guessed payload, which is what erasure means
|
|
34
|
+
-- here. Unlike row_hash, an erasure IS allowed to null
|
|
35
|
+
-- this, so the guard's allowed list grows to include it.
|
|
36
|
+
-- erasure_hash -- set only once content_salt is nulled: sha256 over
|
|
37
|
+
-- { row_hash, erased_at }, so the fact and the instant
|
|
38
|
+
-- of erasure stay checkable forever even though the
|
|
39
|
+
-- erased content no longer is.
|
|
40
|
+
--
|
|
41
|
+
-- A row written before this migration applies has row_hash null forever --
|
|
42
|
+
-- nothing recorded its hash at write time, so computing one now would only
|
|
43
|
+
-- hash what the row happens to contain today, proving nothing about
|
|
44
|
+
-- whether it changed since. verifyChain reports it 'unsealed', not
|
|
45
|
+
-- verified, the same stance 0002's target_display took on the rows before
|
|
46
|
+
-- it and worker_log_chain took on the rows before T255.
|
|
47
|
+
--
|
|
48
|
+
-- audit_seal_erasure(row_id, erasure_hash_value): the runtime role has no
|
|
49
|
+
-- UPDATE on audit_events at all (0001's revoke), so ledger.erase()'s
|
|
50
|
+
-- pending-erasure sweep -- setting content_salt to null and erasure_hash
|
|
51
|
+
-- once a chain-sealed row has been erased -- needs its own security
|
|
52
|
+
-- definer door back in, the same shape as audit_erase_person's.
|
|
53
|
+
|
|
54
|
+
alter table audit_events add column if not exists prev_hash text;
|
|
55
|
+
alter table audit_events add column if not exists row_hash text;
|
|
56
|
+
alter table audit_events add column if not exists content_hash text;
|
|
57
|
+
alter table audit_events add column if not exists content_salt text;
|
|
58
|
+
alter table audit_events add column if not exists erasure_hash text;
|
|
59
|
+
|
|
60
|
+
do $$ begin
|
|
61
|
+
alter table audit_events
|
|
62
|
+
add constraint audit_events_prev_hash_check
|
|
63
|
+
check (prev_hash is null or prev_hash ~ '^[0-9a-f]{64}$');
|
|
64
|
+
exception when duplicate_object then null; end $$;
|
|
65
|
+
|
|
66
|
+
do $$ begin
|
|
67
|
+
alter table audit_events
|
|
68
|
+
add constraint audit_events_row_hash_check
|
|
69
|
+
check (row_hash is null or row_hash ~ '^[0-9a-f]{64}$');
|
|
70
|
+
exception when duplicate_object then null; end $$;
|
|
71
|
+
|
|
72
|
+
do $$ begin
|
|
73
|
+
alter table audit_events
|
|
74
|
+
add constraint audit_events_content_hash_check
|
|
75
|
+
check (content_hash is null or content_hash ~ '^[0-9a-f]{64}$');
|
|
76
|
+
exception when duplicate_object then null; end $$;
|
|
77
|
+
|
|
78
|
+
do $$ begin
|
|
79
|
+
alter table audit_events
|
|
80
|
+
add constraint audit_events_erasure_hash_check
|
|
81
|
+
check (erasure_hash is null or erasure_hash ~ '^[0-9a-f]{64}$');
|
|
82
|
+
exception when duplicate_object then null; end $$;
|
|
83
|
+
|
|
84
|
+
-- A chain-sealed row not yet erased, so ledger.erase()'s pending-erasure
|
|
85
|
+
-- sweep finds it in an index scan rather than a table scan. Empty on a
|
|
86
|
+
-- host that never turns chaining on.
|
|
87
|
+
create index if not exists audit_events_chain_pending_idx
|
|
88
|
+
on audit_events (id)
|
|
89
|
+
where row_hash is not null and erased_at is not null and erasure_hash is null;
|
|
90
|
+
|
|
91
|
+
-- 0002's guard, with content_salt and erasure_hash added to the columns an
|
|
92
|
+
-- update may change: erasure nulls the first and sets the second, on a row
|
|
93
|
+
-- that was already erased by the same audit_erase_person call this
|
|
94
|
+
-- migration does not touch. prev_hash, row_hash and content_hash are
|
|
95
|
+
-- absent from the list on purpose -- once set, never.
|
|
96
|
+
create or replace function audit_events_guard() returns trigger
|
|
97
|
+
language plpgsql as $$
|
|
98
|
+
begin
|
|
99
|
+
if tg_op = 'DELETE' then
|
|
100
|
+
raise exception 'audit_events is append-only: delete refused';
|
|
101
|
+
elsif tg_op = 'TRUNCATE' then
|
|
102
|
+
raise exception 'audit_events is append-only: truncate refused';
|
|
103
|
+
elsif tg_op = 'UPDATE' then
|
|
104
|
+
if new.id is distinct from old.id
|
|
105
|
+
or new.occurred_at is distinct from old.occurred_at
|
|
106
|
+
or new.tenant_id is distinct from old.tenant_id
|
|
107
|
+
or new.tenant_display is distinct from old.tenant_display
|
|
108
|
+
or new.actor_class is distinct from old.actor_class
|
|
109
|
+
or new.actor_id is distinct from old.actor_id
|
|
110
|
+
or new.action is distinct from old.action
|
|
111
|
+
or new.target_type is distinct from old.target_type
|
|
112
|
+
or new.target_id is distinct from old.target_id
|
|
113
|
+
or new.outcome is distinct from old.outcome
|
|
114
|
+
or new.context is distinct from old.context
|
|
115
|
+
or new.session_id is distinct from old.session_id
|
|
116
|
+
or new.reason is distinct from old.reason
|
|
117
|
+
or new.reference is distinct from old.reference
|
|
118
|
+
or new.request_id is distinct from old.request_id
|
|
119
|
+
or new.ip is distinct from old.ip
|
|
120
|
+
or new.user_agent is distinct from old.user_agent
|
|
121
|
+
or new.tenant_visible is distinct from old.tenant_visible
|
|
122
|
+
or new.schema_version is distinct from old.schema_version
|
|
123
|
+
or new.subject_class is distinct from old.subject_class
|
|
124
|
+
or new.subject_id is distinct from old.subject_id
|
|
125
|
+
or new.prev_hash is distinct from old.prev_hash
|
|
126
|
+
or new.row_hash is distinct from old.row_hash
|
|
127
|
+
or new.content_hash is distinct from old.content_hash
|
|
128
|
+
then
|
|
129
|
+
raise exception 'audit_events is append-only: only actor_display, target_display, before, after, erased_at, content_salt and erasure_hash may change';
|
|
130
|
+
end if;
|
|
131
|
+
end if;
|
|
132
|
+
return new;
|
|
133
|
+
end
|
|
134
|
+
$$;
|
|
135
|
+
|
|
136
|
+
-- 0001's revoke took update off the runtime role entirely, on purpose:
|
|
137
|
+
-- audit_erase_person (security definer) is the one door back in. This is
|
|
138
|
+
-- the second one, for exactly one thing: chain-sealing a row
|
|
139
|
+
-- audit_erase_person already erased. It never computes a hash itself --
|
|
140
|
+
-- the caller does, in TypeScript (chain.ts's computeErasureHash), the same
|
|
141
|
+
-- design choice audit_erase_person's own absence of a hash column made
|
|
142
|
+
-- before this file existed -- it only writes the value it is given, and
|
|
143
|
+
-- only onto a row that is erased, chain-sealed, and not already sealed.
|
|
144
|
+
create or replace function audit_seal_erasure(row_id bigint, erasure_hash_value text)
|
|
145
|
+
returns void
|
|
146
|
+
language plpgsql
|
|
147
|
+
security definer
|
|
148
|
+
set search_path from current
|
|
149
|
+
as $$
|
|
150
|
+
begin
|
|
151
|
+
update audit_events
|
|
152
|
+
set content_salt = null,
|
|
153
|
+
erasure_hash = erasure_hash_value
|
|
154
|
+
where id = row_id
|
|
155
|
+
and row_hash is not null
|
|
156
|
+
and erased_at is not null
|
|
157
|
+
and erasure_hash is null;
|
|
158
|
+
end
|
|
159
|
+
$$;
|
|
160
|
+
revoke all on function audit_seal_erasure(bigint, text) from public;
|
|
161
|
+
|
|
162
|
+
do $$
|
|
163
|
+
declare
|
|
164
|
+
rt text := current_database() || '_rt';
|
|
165
|
+
begin
|
|
166
|
+
if exists (select 1 from pg_roles where rolname = rt) then
|
|
167
|
+
execute format('grant execute on function audit_seal_erasure(bigint, text) to %I', rt);
|
|
168
|
+
end if;
|
|
169
|
+
end
|
|
170
|
+
$$;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
-- Copied verbatim from @wtfalch/audit 0.6.0's own migrations/ (#232), the
|
|
2
|
+
-- same way that package's own audit-migrations CLI copies them into a host's
|
|
3
|
+
-- drizzle/ directory -- foundry has no drizzle/ journal, so this file is
|
|
4
|
+
-- that copy, hand-placed at the next free number instead. Never edit the
|
|
5
|
+
-- copied SQL below; a fix ships as a new numbered file, same as upstream.
|
|
6
|
+
--
|
|
7
|
+
-- @wtfalch/audit: row-level security on audit_events.
|
|
8
|
+
--
|
|
9
|
+
-- Mirrors src/ledger.ts (scopeAuditTenant). Every statement is idempotent.
|
|
10
|
+
-- Applied by the host's own migrate script after the host copies this file
|
|
11
|
+
-- into its drizzle/ directory as the next number (audit-migrations); never
|
|
12
|
+
-- edited there.
|
|
13
|
+
--
|
|
14
|
+
-- Until this file, tenant isolation was only the `tenantId` a caller
|
|
15
|
+
-- remembered to pass to ledger.page(). One missed predicate showed a
|
|
16
|
+
-- customer another company's trail. With RLS on, the database holds the
|
|
17
|
+
-- line for every role that is not the table's owner -- the runtime role
|
|
18
|
+
-- <database>_rt, and any read-only role a host adds:
|
|
19
|
+
--
|
|
20
|
+
-- audit.tenant_id -- set per transaction by the host
|
|
21
|
+
-- (scopeAuditTenant, or
|
|
22
|
+
-- set_config('audit.tenant_id', $1, true)).
|
|
23
|
+
-- While set, a SELECT sees that tenant's rows and
|
|
24
|
+
-- nothing else -- not another tenant's, and not
|
|
25
|
+
-- the estate's own tenant_id-null rows.
|
|
26
|
+
-- audit.require_tenant -- 'on' makes an unscoped SELECT see nothing
|
|
27
|
+
-- instead of everything. Set it on a role, once:
|
|
28
|
+
-- alter role <database>_rt set audit.require_tenant = 'on';
|
|
29
|
+
-- That is the setting that survives a forgotten
|
|
30
|
+
-- scope. It is not set here because an operator
|
|
31
|
+
-- surface reads across tenants on purpose, and
|
|
32
|
+
-- which role does that is the host's decision.
|
|
33
|
+
--
|
|
34
|
+
-- Unset and not required, a SELECT sees every row, exactly as before this
|
|
35
|
+
-- file: applying it changes nothing for a host until the host scopes.
|
|
36
|
+
--
|
|
37
|
+
-- INSERT is not restricted: a request scoped to one tenant may still record
|
|
38
|
+
-- an estate-level (tenant_id null) event, and the ledger's own vocabulary
|
|
39
|
+
-- checks already decide what may be written. UPDATE and DELETE have no
|
|
40
|
+
-- policy, so they stay refused for the runtime role -- which 0001's revoke
|
|
41
|
+
-- already did.
|
|
42
|
+
--
|
|
43
|
+
-- Not FORCE: the owner, and the security definer functions it owns
|
|
44
|
+
-- (audit_erase_person, audit_seal_erasure, and the two below), bypass RLS.
|
|
45
|
+
-- Erasure is by subject, across tenants, by design.
|
|
46
|
+
|
|
47
|
+
alter table audit_events enable row level security;
|
|
48
|
+
|
|
49
|
+
drop policy if exists audit_events_read on audit_events;
|
|
50
|
+
create policy audit_events_read on audit_events
|
|
51
|
+
for select
|
|
52
|
+
using (
|
|
53
|
+
case
|
|
54
|
+
when coalesce(current_setting('audit.tenant_id', true), '') <> ''
|
|
55
|
+
then tenant_id is not null
|
|
56
|
+
and tenant_id = current_setting('audit.tenant_id', true)::uuid
|
|
57
|
+
when coalesce(current_setting('audit.require_tenant', true), '') = 'on'
|
|
58
|
+
then false
|
|
59
|
+
else true
|
|
60
|
+
end
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
drop policy if exists audit_events_append on audit_events;
|
|
64
|
+
create policy audit_events_append on audit_events
|
|
65
|
+
for insert
|
|
66
|
+
with check (true);
|
|
67
|
+
|
|
68
|
+
-- The hash chain is one chain across every tenant. Under a tenant scope the
|
|
69
|
+
-- runtime role can no longer see the true tail, so ledger.sign() with
|
|
70
|
+
-- hashChain on would chain onto that tenant's last row and fork the chain.
|
|
71
|
+
-- This reads the tail as the owner. It returns a hash, never content.
|
|
72
|
+
create or replace function audit_chain_tail()
|
|
73
|
+
returns text
|
|
74
|
+
language sql
|
|
75
|
+
stable
|
|
76
|
+
security definer
|
|
77
|
+
set search_path from current
|
|
78
|
+
as $$
|
|
79
|
+
select row_hash from audit_events order by id desc limit 1
|
|
80
|
+
$$;
|
|
81
|
+
revoke all on function audit_chain_tail() from public;
|
|
82
|
+
|
|
83
|
+
-- The same for ledger.erase()'s pending-erasure sweep, which must find
|
|
84
|
+
-- every tenant's pending rows, scoped or not. Ids, hashes and erasure
|
|
85
|
+
-- instants only.
|
|
86
|
+
create or replace function audit_pending_erasures()
|
|
87
|
+
returns table (id bigint, row_hash text, erased_at timestamptz)
|
|
88
|
+
language sql
|
|
89
|
+
stable
|
|
90
|
+
security definer
|
|
91
|
+
set search_path from current
|
|
92
|
+
as $$
|
|
93
|
+
select e.id, e.row_hash, e.erased_at
|
|
94
|
+
from audit_events e
|
|
95
|
+
where e.row_hash is not null
|
|
96
|
+
and e.erased_at is not null
|
|
97
|
+
and e.erasure_hash is null
|
|
98
|
+
order by e.id
|
|
99
|
+
$$;
|
|
100
|
+
revoke all on function audit_pending_erasures() from public;
|
|
101
|
+
|
|
102
|
+
do $$
|
|
103
|
+
declare
|
|
104
|
+
rt text := current_database() || '_rt';
|
|
105
|
+
begin
|
|
106
|
+
if exists (select 1 from pg_roles where rolname = rt) then
|
|
107
|
+
execute format('grant execute on function audit_chain_tail() to %I', rt);
|
|
108
|
+
execute format('grant execute on function audit_pending_erasures() to %I', rt);
|
|
109
|
+
end if;
|
|
110
|
+
end
|
|
111
|
+
$$;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Absolute path of the directory holding this package's numbered `.sql`
|
|
3
|
+
* migrations. The host passes it to `runMigrationSources` as
|
|
4
|
+
* `{ name: 'foundry', dir: migrationsDir }`.
|
|
5
|
+
*
|
|
6
|
+
* Its own subpath (`@wtfalch/foundry/migrations-dir`) and no other imports: a
|
|
7
|
+
* migration image imports this without the rest of the package.
|
|
8
|
+
*/
|
|
9
|
+
export declare const migrationsDir: string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
/**
|
|
3
|
+
* Absolute path of the directory holding this package's numbered `.sql`
|
|
4
|
+
* migrations. The host passes it to `runMigrationSources` as
|
|
5
|
+
* `{ name: 'foundry', dir: migrationsDir }`.
|
|
6
|
+
*
|
|
7
|
+
* Its own subpath (`@wtfalch/foundry/migrations-dir`) and no other imports: a
|
|
8
|
+
* migration image imports this without the rest of the package.
|
|
9
|
+
*/
|
|
10
|
+
export const migrationsDir = fileURLToPath(new URL('./migrations/', import.meta.url));
|
package/dist/org.d.ts
CHANGED
|
@@ -99,6 +99,12 @@ export declare const DEFAULT_NO_REPLY_LOCAL_PART = "no-reply";
|
|
|
99
99
|
* Never fired by `domain` alone (#7 amendment 1).
|
|
100
100
|
*/
|
|
101
101
|
export interface ChatConfig {
|
|
102
|
+
/**
|
|
103
|
+
* Chat is not in v1 (decided 2026-10-01): off by default for every org.
|
|
104
|
+
* While off, `apply` plans no chat step and leaves a live chat resource
|
|
105
|
+
* alone, listed in `unmanaged`; Agora hides all chat and call UI.
|
|
106
|
+
*/
|
|
107
|
+
enabled: boolean;
|
|
102
108
|
usesDomain: boolean;
|
|
103
109
|
}
|
|
104
110
|
/**
|
|
@@ -214,7 +220,25 @@ export interface HostingConfig {
|
|
|
214
220
|
* means no Access gate. Requires `enabled`.
|
|
215
221
|
*/
|
|
216
222
|
accessEmails: string[];
|
|
223
|
+
/**
|
|
224
|
+
* Maps the app's own env var name to one of `AppEnvSource`'s values --
|
|
225
|
+
* each a value the plan already mints for this org (Files, AI, mail) but
|
|
226
|
+
* never used to push an app's own env (#243). Each bespoke app names these
|
|
227
|
+
* differently (lokessmie uses `STORAGE_*`/`AI_*`); this is how an org
|
|
228
|
+
* declares its own app's names for them. `{}` means the app names none of
|
|
229
|
+
* them, so `pushAppEnv` pushes nothing from this map.
|
|
230
|
+
*/
|
|
231
|
+
appEnv: Record<string, AppEnvSource>;
|
|
217
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* The closed set of values the plan already mints that `HostingConfig.appEnv`
|
|
235
|
+
* may map an app's env var name to (#243): the org's own Files organisation
|
|
236
|
+
* id, its AI organisation id, and the mail domain its Lettermint project
|
|
237
|
+
* sends from. Each is read from `StepExecutionContext.recordedIds()` in
|
|
238
|
+
* `executor.ts`'s `pushAppEnv`, never minted again there.
|
|
239
|
+
*/
|
|
240
|
+
export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain';
|
|
241
|
+
export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
|
|
218
242
|
/**
|
|
219
243
|
* `null` when `hosting` is valid; otherwise the reason it is not (#71), same
|
|
220
244
|
* shape as `validateBuyerIdentity` below.
|
package/dist/org.js
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
|
|
3
3
|
/** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
|
|
4
4
|
export const DEFAULT_NO_REPLY_LOCAL_PART = 'no-reply';
|
|
5
|
+
export const APP_ENV_SOURCES = new Set([
|
|
6
|
+
'filesOrganisationId',
|
|
7
|
+
'aiOrganisationId',
|
|
8
|
+
'mailDomain',
|
|
9
|
+
]);
|
|
5
10
|
const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
|
|
6
11
|
const HOSTING_BUILD_PACKS = new Set([
|
|
7
12
|
'dockerfile',
|
|
@@ -62,6 +67,14 @@ export function validateHosting(hosting) {
|
|
|
62
67
|
return `issuerApps '${app.name}' is reserved for the always-on Agora client every org gets`;
|
|
63
68
|
}
|
|
64
69
|
}
|
|
70
|
+
for (const [envName, source] of Object.entries(hosting.appEnv)) {
|
|
71
|
+
if (!envName) {
|
|
72
|
+
return 'every appEnv key needs a non-empty env var name';
|
|
73
|
+
}
|
|
74
|
+
if (!APP_ENV_SOURCES.has(source)) {
|
|
75
|
+
return `appEnv '${envName}' has an unknown source '${source}'`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
65
78
|
return null;
|
|
66
79
|
}
|
|
67
80
|
/**
|
package/dist/plan/agora.d.ts
CHANGED
|
@@ -13,6 +13,27 @@ import type { DeclaredOrg, OrgId } from '../org.js';
|
|
|
13
13
|
*/
|
|
14
14
|
export declare const ZITADEL_ORG_SCOPE_KEY = "zitadel_org";
|
|
15
15
|
export declare const AGORA_CLIENT_ID_SCOPE_KEY = "agora_client_id";
|
|
16
|
+
/**
|
|
17
|
+
* The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
|
|
18
|
+
* not a `system:kind:key` resource id (that slot already holds the app's
|
|
19
|
+
* `appId`, `zitadelApp`'s own `resourceId`) and not a secret (`deliverSecret`
|
|
20
|
+
* is for that), so a flat scope id like `zitadel_org`/`zitadel_project`
|
|
21
|
+
* above. A level-1 org declares exactly one web `issuerApps` entry for its
|
|
22
|
+
* one standard app, so one flat key is enough; an org with more than one web
|
|
23
|
+
* app would need a richer key, not built here (Finding, this campaign's
|
|
24
|
+
* report).
|
|
25
|
+
*/
|
|
26
|
+
export declare const APP_CLIENT_ID_SCOPE_KEY = "zitadel_app_client_id";
|
|
27
|
+
/**
|
|
28
|
+
* #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
|
|
29
|
+
* separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
|
|
30
|
+
* org's own hosted app (`diff.ts`'s `planIssuerApps`), a different
|
|
31
|
+
* `zitadel:app` step from the fixed `cms` client `diff.ts`'s `planCmsClient`
|
|
32
|
+
* plans. `cmsSite` reads this key to push `AUTH_CLIENT_ID` onto the cms app,
|
|
33
|
+
* the same way `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` for the hosted
|
|
34
|
+
* app.
|
|
35
|
+
*/
|
|
36
|
+
export declare const CMS_CLIENT_ID_SCOPE_KEY = "cms_client_id";
|
|
16
37
|
/**
|
|
17
38
|
* The accent every org's Agora wears until orgs choose their own: the house
|
|
18
39
|
* theme's `--accent` (`@wtfalch/house`, `HOUSE_THEMES['good-company']` in
|
package/dist/plan/agora.js
CHANGED
|
@@ -12,6 +12,27 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
|
|
14
14
|
export const AGORA_CLIENT_ID_SCOPE_KEY = 'agora_client_id';
|
|
15
|
+
/**
|
|
16
|
+
* The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
|
|
17
|
+
* not a `system:kind:key` resource id (that slot already holds the app's
|
|
18
|
+
* `appId`, `zitadelApp`'s own `resourceId`) and not a secret (`deliverSecret`
|
|
19
|
+
* is for that), so a flat scope id like `zitadel_org`/`zitadel_project`
|
|
20
|
+
* above. A level-1 org declares exactly one web `issuerApps` entry for its
|
|
21
|
+
* one standard app, so one flat key is enough; an org with more than one web
|
|
22
|
+
* app would need a richer key, not built here (Finding, this campaign's
|
|
23
|
+
* report).
|
|
24
|
+
*/
|
|
25
|
+
export const APP_CLIENT_ID_SCOPE_KEY = 'zitadel_app_client_id';
|
|
26
|
+
/**
|
|
27
|
+
* #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
|
|
28
|
+
* separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
|
|
29
|
+
* org's own hosted app (`diff.ts`'s `planIssuerApps`), a different
|
|
30
|
+
* `zitadel:app` step from the fixed `cms` client `diff.ts`'s `planCmsClient`
|
|
31
|
+
* plans. `cmsSite` reads this key to push `AUTH_CLIENT_ID` onto the cms app,
|
|
32
|
+
* the same way `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` for the hosted
|
|
33
|
+
* app.
|
|
34
|
+
*/
|
|
35
|
+
export const CMS_CLIENT_ID_SCOPE_KEY = 'cms_client_id';
|
|
15
36
|
/**
|
|
16
37
|
* The accent every org's Agora wears until orgs choose their own: the house
|
|
17
38
|
* theme's `--accent` (`@wtfalch/house`, `HOUSE_THEMES['good-company']` in
|
|
@@ -85,6 +106,8 @@ export function desiredAgoraEnv(org, recorded, host) {
|
|
|
85
106
|
organisationId: zitadelOrgId,
|
|
86
107
|
mailProjectId: host.mailProjectId,
|
|
87
108
|
messagingProjectId: host.messagingProjectId,
|
|
109
|
+
// Chat is not in v1: Agora hides all chat UI unless this is true.
|
|
110
|
+
chat: { enabled: org.chat.enabled },
|
|
88
111
|
});
|
|
89
112
|
}
|
|
90
113
|
if (org.mail.mode !== 'off' && host.mailUpstream) {
|
|
@@ -45,8 +45,10 @@
|
|
|
45
45
|
* (`executor.ts`'s `callsHostnameFor`).
|
|
46
46
|
*/
|
|
47
47
|
export declare const SYNAPSE_IMAGE = "ghcr.io/element-hq/synapse";
|
|
48
|
-
export declare const SYNAPSE_TAG
|
|
49
|
-
export declare const MAS_IMAGE
|
|
48
|
+
export declare const SYNAPSE_TAG: string;
|
|
49
|
+
export declare const MAS_IMAGE: string;
|
|
50
|
+
/** #251: the version baked into {@link MAS_IMAGE}'s `FROM` line, named on its own so a version-change line can read it without re-parsing the image string. */
|
|
51
|
+
export declare const MAS_TAG: string;
|
|
50
52
|
/**
|
|
51
53
|
* Never resolved by DNS and never federates (`callsSynapseHomeserverYaml`'s
|
|
52
54
|
* `federation_domain_whitelist: []`) -- purely a logical identifier MAS and
|