@govcore/schema 0.1.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/migrate.d.ts +11 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +64 -0
- package/dist/schema.d.ts +2024 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +231 -0
- package/migrations/0000_platform_init.sql +97 -0
- package/migrations/0001_platform_security.sql +59 -0
- package/migrations/0002_platform_federation_support.sql +103 -0
- package/package.json +35 -0
- package/src/index.ts +21 -0
- package/src/migrate.ts +85 -0
- package/src/schema.ts +311 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"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"}
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// @govcore/schema — platform table definitions (identity, tenancy, auth, audit).
|
|
2
|
+
//
|
|
3
|
+
// Edge-safe: imports only `drizzle-orm/pg-core`, no DB client. The app imports
|
|
4
|
+
// these for type-safe queries and to declare foreign keys *to* platform tables.
|
|
5
|
+
// The govcore-migrate runner (./migrate) is a separate, non-edge entrypoint.
|
|
6
|
+
//
|
|
7
|
+
// All platform tables live in a dedicated `govcore` Postgres schema (design
|
|
8
|
+
// §13.4) so they never collide with an app's `public` tables and ownership is
|
|
9
|
+
// obvious. The authored DDL lives in ../migrations and must stay in sync.
|
|
10
|
+
import { boolean, integer, jsonb, pgSchema, text, timestamp, unique, uniqueIndex, uuid, } from 'drizzle-orm/pg-core';
|
|
11
|
+
/** The Postgres schema every GovCore platform table belongs to. */
|
|
12
|
+
export const govcore = pgSchema('govcore');
|
|
13
|
+
/** Content/federation visibility. Used by the federation package (later phase). */
|
|
14
|
+
export const visibility = govcore.enum('visibility', ['org', 'connections', 'instance']);
|
|
15
|
+
/** Generic workflow status shared by federation connections and links. */
|
|
16
|
+
export const federationStatus = govcore.enum('federation_status', ['pending', 'active', 'rejected']);
|
|
17
|
+
// ── Tenancy root ────────────────────────────────────────────────────────────
|
|
18
|
+
export const organizations = govcore.table('organizations', {
|
|
19
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
20
|
+
name: text('name').notNull(),
|
|
21
|
+
slug: text('slug').notNull(),
|
|
22
|
+
/** App-extensible bag for org settings GovCore itself doesn't model. */
|
|
23
|
+
metadata: jsonb('metadata').notNull().default({}),
|
|
24
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
25
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
26
|
+
}, (t) => [uniqueIndex('organizations_slug_unique').on(t.slug)]);
|
|
27
|
+
/**
|
|
28
|
+
* The tenancy column contract (design §5): every tenant-scoped table — core's
|
|
29
|
+
* and the app's own domain tables — carries `organization_id` referencing
|
|
30
|
+
* `organizations`. RLS policies (see ../migrations/0001) key off this column.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* export const permits = pgTable('permits', { ...orgScoped(organizations), title: text('title') })
|
|
34
|
+
*/
|
|
35
|
+
export const orgScoped = (orgs) => ({
|
|
36
|
+
organizationId: uuid('organization_id')
|
|
37
|
+
.notNull()
|
|
38
|
+
.references(() => orgs.id, { onDelete: 'cascade' }),
|
|
39
|
+
});
|
|
40
|
+
// ── Identity ────────────────────────────────────────────────────────────────
|
|
41
|
+
export const users = govcore.table('users', {
|
|
42
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
43
|
+
organizationId: uuid('organization_id')
|
|
44
|
+
.notNull()
|
|
45
|
+
.references(() => organizations.id, { onDelete: 'cascade' }),
|
|
46
|
+
/** Last-selected active org, honored first by active-membership resolution. */
|
|
47
|
+
lastActiveOrganizationId: uuid('last_active_organization_id').references(() => organizations.id, { onDelete: 'set null' }),
|
|
48
|
+
name: text('name'),
|
|
49
|
+
email: text('email').notNull(),
|
|
50
|
+
emailVerified: timestamp('email_verified'),
|
|
51
|
+
image: text('image'),
|
|
52
|
+
passwordHash: text('password_hash'),
|
|
53
|
+
/**
|
|
54
|
+
* App-defined role (see @govcore/rbac `createRbac`) — stored as `text`, not
|
|
55
|
+
* a fixed enum, so GovCore ships no role vocabulary of its own. Denormalized
|
|
56
|
+
* cache of the active membership's role; nullable so the Auth.js adapter can
|
|
57
|
+
* create a user before a membership is assigned.
|
|
58
|
+
*/
|
|
59
|
+
role: text('role'),
|
|
60
|
+
/** Platform-level role (e.g. instance/platform admin), separate from per-org role. */
|
|
61
|
+
instanceRole: text('instance_role'),
|
|
62
|
+
isActive: boolean('is_active').notNull().default(true),
|
|
63
|
+
// Account-lockout + password-expiry state; the policy itself lives app-side.
|
|
64
|
+
failedLoginAttempts: integer('failed_login_attempts').notNull().default(0),
|
|
65
|
+
lockoutUntil: timestamp('lockout_until'),
|
|
66
|
+
lastPasswordChangedAt: timestamp('last_password_changed_at').notNull().defaultNow(),
|
|
67
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
68
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
69
|
+
},
|
|
70
|
+
// One identity per email across all orgs — unambiguous auth/SSO lookups.
|
|
71
|
+
(t) => [uniqueIndex('users_email_unique').on(t.email)]);
|
|
72
|
+
// Auth.js (drizzle-adapter) tables. Not org-scoped; read during auth flows
|
|
73
|
+
// before a tenant context exists, so they are intentionally outside RLS.
|
|
74
|
+
export const accounts = govcore.table('accounts', {
|
|
75
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
76
|
+
userId: uuid('user_id')
|
|
77
|
+
.notNull()
|
|
78
|
+
.references(() => users.id, { onDelete: 'cascade' }),
|
|
79
|
+
type: text('type').notNull(),
|
|
80
|
+
provider: text('provider').notNull(),
|
|
81
|
+
providerAccountId: text('provider_account_id').notNull(),
|
|
82
|
+
refreshToken: text('refresh_token'),
|
|
83
|
+
accessToken: text('access_token'),
|
|
84
|
+
expiresAt: timestamp('expires_at'),
|
|
85
|
+
tokenType: text('token_type'),
|
|
86
|
+
scope: text('scope'),
|
|
87
|
+
idToken: text('id_token'),
|
|
88
|
+
sessionState: text('session_state'),
|
|
89
|
+
});
|
|
90
|
+
export const sessions = govcore.table('sessions', {
|
|
91
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
92
|
+
sessionToken: text('session_token').notNull().unique(),
|
|
93
|
+
userId: uuid('user_id')
|
|
94
|
+
.notNull()
|
|
95
|
+
.references(() => users.id, { onDelete: 'cascade' }),
|
|
96
|
+
expires: timestamp('expires').notNull(),
|
|
97
|
+
});
|
|
98
|
+
export const verificationTokens = govcore.table('verification_tokens', {
|
|
99
|
+
identifier: text('identifier').notNull(),
|
|
100
|
+
token: text('token').notNull(),
|
|
101
|
+
expires: timestamp('expires').notNull(),
|
|
102
|
+
});
|
|
103
|
+
// ── Membership model (the heart of tenancy) ─────────────────────────────────
|
|
104
|
+
export const userOrganizationMemberships = govcore.table('user_organization_memberships', {
|
|
105
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
106
|
+
userId: uuid('user_id')
|
|
107
|
+
.notNull()
|
|
108
|
+
.references(() => users.id, { onDelete: 'cascade' }),
|
|
109
|
+
organizationId: uuid('organization_id')
|
|
110
|
+
.notNull()
|
|
111
|
+
.references(() => organizations.id, { onDelete: 'cascade' }),
|
|
112
|
+
/** App-defined role for this membership (text, not an enum — see `users.role`). */
|
|
113
|
+
role: text('role').notNull(),
|
|
114
|
+
/** Revocation soft-deactivates so the historical row survives for audit. */
|
|
115
|
+
isActive: boolean('is_active').notNull().default(true),
|
|
116
|
+
isPrimary: boolean('is_primary').notNull().default(false),
|
|
117
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
118
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
119
|
+
}, (t) => [uniqueIndex('user_org_membership_unique').on(t.userId, t.organizationId)]);
|
|
120
|
+
// ── Audit (append-only; immutability + RLS in ../migrations/0001) ───────────
|
|
121
|
+
export const auditLog = govcore.table('audit_log', {
|
|
122
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
123
|
+
organizationId: uuid('organization_id'), // historical UUID — no FK on purpose
|
|
124
|
+
userId: uuid('user_id'), // historical UUID — no FK on purpose
|
|
125
|
+
action: text('action').notNull(),
|
|
126
|
+
entityType: text('entity_type').notNull(),
|
|
127
|
+
entityId: uuid('entity_id'),
|
|
128
|
+
before: jsonb('before'),
|
|
129
|
+
after: jsonb('after'),
|
|
130
|
+
metadata: jsonb('metadata'),
|
|
131
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
132
|
+
});
|
|
133
|
+
// ── Federation (cross-organization connections and content links) ──────────
|
|
134
|
+
/** Explicit bilateral connection between two organizations. */
|
|
135
|
+
export const orgConnections = govcore.table('org_connections', {
|
|
136
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
137
|
+
fromOrgId: uuid('from_org_id')
|
|
138
|
+
.notNull()
|
|
139
|
+
.references(() => organizations.id, { onDelete: 'cascade' }),
|
|
140
|
+
toOrgId: uuid('to_org_id')
|
|
141
|
+
.notNull()
|
|
142
|
+
.references(() => organizations.id, { onDelete: 'cascade' }),
|
|
143
|
+
status: federationStatus('status').notNull().default('pending'),
|
|
144
|
+
createdBy: uuid('created_by').references(() => users.id),
|
|
145
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
146
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
147
|
+
}, (t) => [unique('unique_org_connection').on(t.fromOrgId, t.toOrgId)]);
|
|
148
|
+
/** Approved relationship between content items across orgs. No FK on entity ids
|
|
149
|
+
* (they cross org boundaries); link semantics are app-defined (`link_type` text). */
|
|
150
|
+
export const crossOrgLinks = govcore.table('cross_org_links', {
|
|
151
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
152
|
+
sourceOrgId: uuid('source_org_id')
|
|
153
|
+
.notNull()
|
|
154
|
+
.references(() => organizations.id, { onDelete: 'cascade' }),
|
|
155
|
+
sourceEntityType: text('source_entity_type').notNull(),
|
|
156
|
+
sourceEntityId: uuid('source_entity_id').notNull(),
|
|
157
|
+
targetOrgId: uuid('target_org_id')
|
|
158
|
+
.notNull()
|
|
159
|
+
.references(() => organizations.id, { onDelete: 'cascade' }),
|
|
160
|
+
targetEntityType: text('target_entity_type').notNull(),
|
|
161
|
+
targetEntityId: uuid('target_entity_id').notNull(),
|
|
162
|
+
linkType: text('link_type').notNull(),
|
|
163
|
+
status: federationStatus('status').notNull().default('pending'),
|
|
164
|
+
rejectionReason: text('rejection_reason'),
|
|
165
|
+
flaggedForReview: boolean('flagged_for_review').notNull().default(false),
|
|
166
|
+
flagReason: text('flag_reason'),
|
|
167
|
+
createdBy: uuid('created_by').references(() => users.id),
|
|
168
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
169
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
170
|
+
});
|
|
171
|
+
// ── Support access (break-glass + act-as) ──────────────────────────────────
|
|
172
|
+
// Instance-operator constructs that deliberately cross the tenant boundary, so
|
|
173
|
+
// they are NOT under the org-GUC RLS (the actor is an instance admin operating
|
|
174
|
+
// across orgs). Authorization is enforced in @govcore/support (design §6.6).
|
|
175
|
+
export const breakGlassSessions = govcore.table('break_glass_sessions', {
|
|
176
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
177
|
+
instanceAdminId: uuid('instance_admin_id')
|
|
178
|
+
.notNull()
|
|
179
|
+
.references(() => users.id),
|
|
180
|
+
targetOrgId: uuid('target_org_id')
|
|
181
|
+
.notNull()
|
|
182
|
+
.references(() => organizations.id),
|
|
183
|
+
reason: text('reason').notNull(),
|
|
184
|
+
grantedAt: timestamp('granted_at').notNull().defaultNow(),
|
|
185
|
+
expiresAt: timestamp('expires_at').notNull(),
|
|
186
|
+
requiresApproval: boolean('requires_approval').notNull().default(false),
|
|
187
|
+
approvedAt: timestamp('approved_at'),
|
|
188
|
+
approvedBy: uuid('approved_by').references(() => users.id),
|
|
189
|
+
revokedAt: timestamp('revoked_at'),
|
|
190
|
+
revokedBy: uuid('revoked_by').references(() => users.id),
|
|
191
|
+
});
|
|
192
|
+
export const actAsSessions = govcore.table('act_as_sessions', {
|
|
193
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
194
|
+
breakGlassSessionId: uuid('break_glass_session_id')
|
|
195
|
+
.notNull()
|
|
196
|
+
.references(() => breakGlassSessions.id),
|
|
197
|
+
instanceAdminId: uuid('instance_admin_id')
|
|
198
|
+
.notNull()
|
|
199
|
+
.references(() => users.id),
|
|
200
|
+
targetOrgId: uuid('target_org_id')
|
|
201
|
+
.notNull()
|
|
202
|
+
.references(() => organizations.id),
|
|
203
|
+
startedAt: timestamp('started_at').notNull().defaultNow(),
|
|
204
|
+
expiresAt: timestamp('expires_at').notNull(),
|
|
205
|
+
endedAt: timestamp('ended_at'),
|
|
206
|
+
endReason: text('end_reason'),
|
|
207
|
+
});
|
|
208
|
+
export const ACT_AS_DEFAULT_TTL_MINUTES = 30;
|
|
209
|
+
export const ACT_AS_END_REASONS = [
|
|
210
|
+
'admin_ended',
|
|
211
|
+
'expired',
|
|
212
|
+
'parent_revoked',
|
|
213
|
+
'parent_expired',
|
|
214
|
+
];
|
|
215
|
+
// ── Instance / platform configuration (singletons; not org-scoped) ─────────
|
|
216
|
+
export const instanceSettings = govcore.table('instance_settings', {
|
|
217
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
218
|
+
disabledModules: jsonb('disabled_modules').$type().notNull().default({}),
|
|
219
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
220
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
221
|
+
});
|
|
222
|
+
export const platformConfig = govcore.table('platform_config', {
|
|
223
|
+
id: text('id').primaryKey().default('singleton'),
|
|
224
|
+
instanceName: text('instance_name').notNull().default('GovCore'),
|
|
225
|
+
defaultTheme: text('default_theme').notNull().default('base'),
|
|
226
|
+
allowLocalAuth: boolean('allow_local_auth').notNull().default(true),
|
|
227
|
+
/** Stamped onto new orgs at provisioning time. Null means no tier. */
|
|
228
|
+
defaultSupportTier: text('default_support_tier'),
|
|
229
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
230
|
+
updatedBy: uuid('updated_by').references(() => users.id, { onDelete: 'set null' }),
|
|
231
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
-- 0000_platform_init — GovCore platform schema (identity, tenancy, auth, audit).
|
|
2
|
+
--
|
|
3
|
+
-- Authored DDL: core owns the platform migrations (design §5). Keep in sync
|
|
4
|
+
-- with packages/schema/src/schema.ts. Applied by govcore-migrate, in one
|
|
5
|
+
-- transaction, as the owner/DDL role.
|
|
6
|
+
|
|
7
|
+
CREATE SCHEMA IF NOT EXISTS govcore;
|
|
8
|
+
|
|
9
|
+
DO $$ BEGIN
|
|
10
|
+
CREATE TYPE govcore.visibility AS ENUM ('org', 'connections', 'instance');
|
|
11
|
+
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
12
|
+
END $$;
|
|
13
|
+
|
|
14
|
+
CREATE TABLE IF NOT EXISTS govcore.organizations (
|
|
15
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
16
|
+
name text NOT NULL,
|
|
17
|
+
slug text NOT NULL,
|
|
18
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
19
|
+
created_at timestamp NOT NULL DEFAULT now(),
|
|
20
|
+
updated_at timestamp NOT NULL DEFAULT now()
|
|
21
|
+
);
|
|
22
|
+
CREATE UNIQUE INDEX IF NOT EXISTS organizations_slug_unique ON govcore.organizations (slug);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS govcore.users (
|
|
25
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
26
|
+
organization_id uuid NOT NULL REFERENCES govcore.organizations (id) ON DELETE CASCADE,
|
|
27
|
+
last_active_organization_id uuid REFERENCES govcore.organizations (id) ON DELETE SET NULL,
|
|
28
|
+
name text,
|
|
29
|
+
email text NOT NULL,
|
|
30
|
+
email_verified timestamp,
|
|
31
|
+
image text,
|
|
32
|
+
password_hash text,
|
|
33
|
+
role text,
|
|
34
|
+
instance_role text,
|
|
35
|
+
is_active boolean NOT NULL DEFAULT true,
|
|
36
|
+
failed_login_attempts integer NOT NULL DEFAULT 0,
|
|
37
|
+
lockout_until timestamp,
|
|
38
|
+
last_password_changed_at timestamp NOT NULL DEFAULT now(),
|
|
39
|
+
created_at timestamp NOT NULL DEFAULT now(),
|
|
40
|
+
updated_at timestamp NOT NULL DEFAULT now()
|
|
41
|
+
);
|
|
42
|
+
CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique ON govcore.users (email);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS govcore.accounts (
|
|
45
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
46
|
+
user_id uuid NOT NULL REFERENCES govcore.users (id) ON DELETE CASCADE,
|
|
47
|
+
type text NOT NULL,
|
|
48
|
+
provider text NOT NULL,
|
|
49
|
+
provider_account_id text NOT NULL,
|
|
50
|
+
refresh_token text,
|
|
51
|
+
access_token text,
|
|
52
|
+
expires_at timestamp,
|
|
53
|
+
token_type text,
|
|
54
|
+
scope text,
|
|
55
|
+
id_token text,
|
|
56
|
+
session_state text
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS govcore.sessions (
|
|
60
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
61
|
+
session_token text NOT NULL UNIQUE,
|
|
62
|
+
user_id uuid NOT NULL REFERENCES govcore.users (id) ON DELETE CASCADE,
|
|
63
|
+
expires timestamp NOT NULL
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS govcore.verification_tokens (
|
|
67
|
+
identifier text NOT NULL,
|
|
68
|
+
token text NOT NULL,
|
|
69
|
+
expires timestamp NOT NULL
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS govcore.user_organization_memberships (
|
|
73
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
74
|
+
user_id uuid NOT NULL REFERENCES govcore.users (id) ON DELETE CASCADE,
|
|
75
|
+
organization_id uuid NOT NULL REFERENCES govcore.organizations (id) ON DELETE CASCADE,
|
|
76
|
+
role text NOT NULL,
|
|
77
|
+
is_active boolean NOT NULL DEFAULT true,
|
|
78
|
+
is_primary boolean NOT NULL DEFAULT false,
|
|
79
|
+
created_at timestamp NOT NULL DEFAULT now(),
|
|
80
|
+
updated_at timestamp NOT NULL DEFAULT now()
|
|
81
|
+
);
|
|
82
|
+
CREATE UNIQUE INDEX IF NOT EXISTS user_org_membership_unique
|
|
83
|
+
ON govcore.user_organization_memberships (user_id, organization_id);
|
|
84
|
+
|
|
85
|
+
CREATE TABLE IF NOT EXISTS govcore.audit_log (
|
|
86
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
87
|
+
organization_id uuid,
|
|
88
|
+
user_id uuid,
|
|
89
|
+
action text NOT NULL,
|
|
90
|
+
entity_type text NOT NULL,
|
|
91
|
+
entity_id uuid,
|
|
92
|
+
before jsonb,
|
|
93
|
+
after jsonb,
|
|
94
|
+
metadata jsonb,
|
|
95
|
+
created_at timestamp NOT NULL DEFAULT now()
|
|
96
|
+
);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS audit_log_org_idx ON govcore.audit_log (organization_id);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
-- 0001_platform_security — append-only audit + Row-Level Security.
|
|
2
|
+
--
|
|
3
|
+
-- Hand-authored: Drizzle manages neither triggers nor RLS. These are core's
|
|
4
|
+
-- security properties (design §13.1–§13.2), applied by govcore-migrate.
|
|
5
|
+
|
|
6
|
+
-- ── Append-only audit log (carried from GovEA #417) ─────────────────────────
|
|
7
|
+
CREATE OR REPLACE FUNCTION govcore.audit_log_immutable()
|
|
8
|
+
RETURNS trigger LANGUAGE plpgsql AS $$
|
|
9
|
+
BEGIN
|
|
10
|
+
RAISE EXCEPTION 'audit_log is append-only — UPDATE and DELETE are not permitted';
|
|
11
|
+
END;
|
|
12
|
+
$$;
|
|
13
|
+
|
|
14
|
+
DROP TRIGGER IF EXISTS audit_log_no_update ON govcore.audit_log;
|
|
15
|
+
CREATE TRIGGER audit_log_no_update
|
|
16
|
+
BEFORE UPDATE ON govcore.audit_log
|
|
17
|
+
FOR EACH ROW EXECUTE FUNCTION govcore.audit_log_immutable();
|
|
18
|
+
|
|
19
|
+
DROP TRIGGER IF EXISTS audit_log_no_delete ON govcore.audit_log;
|
|
20
|
+
CREATE TRIGGER audit_log_no_delete
|
|
21
|
+
BEFORE DELETE ON govcore.audit_log
|
|
22
|
+
FOR EACH ROW EXECUTE FUNCTION govcore.audit_log_immutable();
|
|
23
|
+
|
|
24
|
+
-- ── Row-Level Security: tenant isolation by the active-org GUC ──────────────
|
|
25
|
+
--
|
|
26
|
+
-- The runtime sets `app.current_org` per request, transaction-locally, inside
|
|
27
|
+
-- the tenant transaction (design §13.1): `select set_config('app.current_org',
|
|
28
|
+
-- $org, true)`. When the GUC is unset, current_setting(..., true) returns NULL,
|
|
29
|
+
-- the comparison is NULL (not true), and every row is hidden — deny by default.
|
|
30
|
+
--
|
|
31
|
+
-- FORCE so the policy binds even for the table owner; the app connects as a
|
|
32
|
+
-- NON-OWNER runtime role (design §13.2). One sanctioned bypass (break-glass /
|
|
33
|
+
-- act-as) is a later phase; nothing bypasses RLS today.
|
|
34
|
+
--
|
|
35
|
+
-- Scope (this slice): users, memberships, audit_log. NOT applied to
|
|
36
|
+
-- `organizations` (the tenant root — visibility is membership-based, a later
|
|
37
|
+
-- policy) or the Auth.js tables (read before a tenant context exists).
|
|
38
|
+
|
|
39
|
+
ALTER TABLE govcore.users ENABLE ROW LEVEL SECURITY;
|
|
40
|
+
ALTER TABLE govcore.users FORCE ROW LEVEL SECURITY;
|
|
41
|
+
DROP POLICY IF EXISTS users_org_isolation ON govcore.users;
|
|
42
|
+
CREATE POLICY users_org_isolation ON govcore.users
|
|
43
|
+
USING (organization_id = nullif(current_setting('app.current_org', true), '')::uuid);
|
|
44
|
+
|
|
45
|
+
ALTER TABLE govcore.user_organization_memberships ENABLE ROW LEVEL SECURITY;
|
|
46
|
+
ALTER TABLE govcore.user_organization_memberships FORCE ROW LEVEL SECURITY;
|
|
47
|
+
DROP POLICY IF EXISTS memberships_org_isolation ON govcore.user_organization_memberships;
|
|
48
|
+
CREATE POLICY memberships_org_isolation ON govcore.user_organization_memberships
|
|
49
|
+
USING (organization_id = nullif(current_setting('app.current_org', true), '')::uuid);
|
|
50
|
+
|
|
51
|
+
ALTER TABLE govcore.audit_log ENABLE ROW LEVEL SECURITY;
|
|
52
|
+
ALTER TABLE govcore.audit_log FORCE ROW LEVEL SECURITY;
|
|
53
|
+
DROP POLICY IF EXISTS audit_org_isolation ON govcore.audit_log;
|
|
54
|
+
-- Reads scoped to the active org (or org-less platform events); inserts allowed
|
|
55
|
+
-- (e.g. cross-org support actions record an audit row). Immutability is the
|
|
56
|
+
-- trigger above; UPDATE/DELETE never reach the policy.
|
|
57
|
+
CREATE POLICY audit_org_isolation ON govcore.audit_log
|
|
58
|
+
USING (organization_id IS NULL OR organization_id = nullif(current_setting('app.current_org', true), '')::uuid)
|
|
59
|
+
WITH CHECK (true);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
-- 0002_platform_federation_support — federation, support access, instance config.
|
|
2
|
+
-- Authored DDL (design §5). Keep in sync with packages/schema/src/schema.ts.
|
|
3
|
+
|
|
4
|
+
DO $$ BEGIN
|
|
5
|
+
CREATE TYPE govcore.federation_status AS ENUM ('pending', 'active', 'rejected');
|
|
6
|
+
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
7
|
+
END $$;
|
|
8
|
+
|
|
9
|
+
-- ── Federation ─────────────────────────────────────────────────────────────
|
|
10
|
+
CREATE TABLE IF NOT EXISTS govcore.org_connections (
|
|
11
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
12
|
+
from_org_id uuid NOT NULL REFERENCES govcore.organizations (id) ON DELETE CASCADE,
|
|
13
|
+
to_org_id uuid NOT NULL REFERENCES govcore.organizations (id) ON DELETE CASCADE,
|
|
14
|
+
status govcore.federation_status NOT NULL DEFAULT 'pending',
|
|
15
|
+
created_by uuid REFERENCES govcore.users (id),
|
|
16
|
+
created_at timestamp NOT NULL DEFAULT now(),
|
|
17
|
+
updated_at timestamp NOT NULL DEFAULT now(),
|
|
18
|
+
CONSTRAINT unique_org_connection UNIQUE (from_org_id, to_org_id)
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS govcore.cross_org_links (
|
|
22
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
23
|
+
source_org_id uuid NOT NULL REFERENCES govcore.organizations (id) ON DELETE CASCADE,
|
|
24
|
+
source_entity_type text NOT NULL,
|
|
25
|
+
source_entity_id uuid NOT NULL,
|
|
26
|
+
target_org_id uuid NOT NULL REFERENCES govcore.organizations (id) ON DELETE CASCADE,
|
|
27
|
+
target_entity_type text NOT NULL,
|
|
28
|
+
target_entity_id uuid NOT NULL,
|
|
29
|
+
link_type text NOT NULL,
|
|
30
|
+
status govcore.federation_status NOT NULL DEFAULT 'pending',
|
|
31
|
+
rejection_reason text,
|
|
32
|
+
flagged_for_review boolean NOT NULL DEFAULT false,
|
|
33
|
+
flag_reason text,
|
|
34
|
+
created_by uuid REFERENCES govcore.users (id),
|
|
35
|
+
created_at timestamp NOT NULL DEFAULT now(),
|
|
36
|
+
updated_at timestamp NOT NULL DEFAULT now()
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
-- ── Support access (break-glass + act-as) ──────────────────────────────────
|
|
40
|
+
CREATE TABLE IF NOT EXISTS govcore.break_glass_sessions (
|
|
41
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
42
|
+
instance_admin_id uuid NOT NULL REFERENCES govcore.users (id),
|
|
43
|
+
target_org_id uuid NOT NULL REFERENCES govcore.organizations (id),
|
|
44
|
+
reason text NOT NULL,
|
|
45
|
+
granted_at timestamp NOT NULL DEFAULT now(),
|
|
46
|
+
expires_at timestamp NOT NULL,
|
|
47
|
+
requires_approval boolean NOT NULL DEFAULT false,
|
|
48
|
+
approved_at timestamp,
|
|
49
|
+
approved_by uuid REFERENCES govcore.users (id),
|
|
50
|
+
revoked_at timestamp,
|
|
51
|
+
revoked_by uuid REFERENCES govcore.users (id)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE TABLE IF NOT EXISTS govcore.act_as_sessions (
|
|
55
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
56
|
+
break_glass_session_id uuid NOT NULL REFERENCES govcore.break_glass_sessions (id),
|
|
57
|
+
instance_admin_id uuid NOT NULL REFERENCES govcore.users (id),
|
|
58
|
+
target_org_id uuid NOT NULL REFERENCES govcore.organizations (id),
|
|
59
|
+
started_at timestamp NOT NULL DEFAULT now(),
|
|
60
|
+
expires_at timestamp NOT NULL,
|
|
61
|
+
ended_at timestamp,
|
|
62
|
+
end_reason text
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
-- ── Instance / platform configuration (singletons) ─────────────────────────
|
|
66
|
+
CREATE TABLE IF NOT EXISTS govcore.instance_settings (
|
|
67
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
68
|
+
disabled_modules jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
69
|
+
created_at timestamp NOT NULL DEFAULT now(),
|
|
70
|
+
updated_at timestamp NOT NULL DEFAULT now()
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE IF NOT EXISTS govcore.platform_config (
|
|
74
|
+
id text PRIMARY KEY DEFAULT 'singleton',
|
|
75
|
+
instance_name text NOT NULL DEFAULT 'GovCore',
|
|
76
|
+
default_theme text NOT NULL DEFAULT 'base',
|
|
77
|
+
allow_local_auth boolean NOT NULL DEFAULT true,
|
|
78
|
+
default_support_tier text,
|
|
79
|
+
updated_at timestamp NOT NULL DEFAULT now(),
|
|
80
|
+
updated_by uuid REFERENCES govcore.users (id) ON DELETE SET NULL
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
-- ── RLS: federation tables are visible to BOTH participating orgs ───────────
|
|
84
|
+
-- The active-org GUC must be one of the two orgs on the row. Unset GUC ⇒ no
|
|
85
|
+
-- match ⇒ denied (deny by default). This is also the WITH CHECK on writes, so
|
|
86
|
+
-- an org can only create a connection/link it participates in.
|
|
87
|
+
ALTER TABLE govcore.org_connections ENABLE ROW LEVEL SECURITY;
|
|
88
|
+
ALTER TABLE govcore.org_connections FORCE ROW LEVEL SECURITY;
|
|
89
|
+
DROP POLICY IF EXISTS org_connections_participant ON govcore.org_connections;
|
|
90
|
+
CREATE POLICY org_connections_participant ON govcore.org_connections
|
|
91
|
+
USING (nullif(current_setting('app.current_org', true), '')::uuid IN (from_org_id, to_org_id));
|
|
92
|
+
|
|
93
|
+
ALTER TABLE govcore.cross_org_links ENABLE ROW LEVEL SECURITY;
|
|
94
|
+
ALTER TABLE govcore.cross_org_links FORCE ROW LEVEL SECURITY;
|
|
95
|
+
DROP POLICY IF EXISTS cross_org_links_participant ON govcore.cross_org_links;
|
|
96
|
+
CREATE POLICY cross_org_links_participant ON govcore.cross_org_links
|
|
97
|
+
USING (nullif(current_setting('app.current_org', true), '')::uuid IN (source_org_id, target_org_id));
|
|
98
|
+
|
|
99
|
+
-- break_glass_sessions, act_as_sessions, instance_settings, platform_config are
|
|
100
|
+
-- intentionally NOT under org-GUC RLS: they are instance-operator / singleton
|
|
101
|
+
-- tables. Authorization is enforced in app/@govcore/support code; a refined
|
|
102
|
+
-- instance-admin RLS bypass is future work (design §6.6, the single sanctioned
|
|
103
|
+
-- cross-org bypass).
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@govcore/schema",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Platform tables, enums, migrations, and the govcore-migrate runner",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.ts",
|
|
11
|
+
"default": "./src/index.ts"
|
|
12
|
+
},
|
|
13
|
+
"./migrate": {
|
|
14
|
+
"types": "./src/migrate.ts",
|
|
15
|
+
"default": "./src/migrate.ts"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"govcore-migrate": "./dist/migrate.js"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"drizzle-orm": "^0.45.2",
|
|
26
|
+
"postgres": "^3.4.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"typescript": "^5.5.0"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc -p tsconfig.json",
|
|
33
|
+
"dev": "tsc -p tsconfig.json --watch"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @govcore/schema — platform table definitions + schema version.
|
|
2
|
+
// Edge-safe entrypoint (no DB client). The migrate runner is ./migrate.
|
|
3
|
+
|
|
4
|
+
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core'
|
|
5
|
+
|
|
6
|
+
export * from './schema'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A Drizzle Postgres database **or** transaction handle. Functions in the
|
|
10
|
+
* tenancy/audit packages accept this so callers inject their own client (the
|
|
11
|
+
* app's `postgres-js` db, or a `tx` inside a transaction — which extends it).
|
|
12
|
+
*/
|
|
13
|
+
export type GovcoreDb = PgDatabase<PgQueryResultHKT, any, any>
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Bumped whenever the platform schema changes. Written to instance settings on
|
|
17
|
+
* boot for observability ("this instance runs platform schema vN", design §5),
|
|
18
|
+
* and stamped into backup archives (design §13.5). Not load-bearing for
|
|
19
|
+
* correctness — the migrations are — but useful for diagnostics.
|
|
20
|
+
*/
|
|
21
|
+
export const CORE_SCHEMA_VERSION = '0.0.0'
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @govcore/schema/migrate — the govcore-migrate runner.
|
|
2
|
+
//
|
|
3
|
+
// NOT edge-safe (uses node:fs + a postgres client). Applies the authored
|
|
4
|
+
// platform migrations in ../migrations in lexical order, each in its own
|
|
5
|
+
// transaction, tracked in `govcore.__govcore_migrations` — kept separate from
|
|
6
|
+
// the app's own Drizzle journal so the two migration streams never overlap
|
|
7
|
+
// (design §5).
|
|
8
|
+
//
|
|
9
|
+
// Runs as the OWNER / DDL role (design §13.2): set GOVCORE_MIGRATE_DATABASE_URL
|
|
10
|
+
// to the owning role's connection string; falls back to DATABASE_URL. The app
|
|
11
|
+
// runtime connects as a separate NON-OWNER role, which RLS binds.
|
|
12
|
+
|
|
13
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import postgres from 'postgres'
|
|
17
|
+
|
|
18
|
+
const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
|
|
19
|
+
|
|
20
|
+
export interface MigrateOptions {
|
|
21
|
+
/** Connection string for the owner/DDL role. Defaults to env. */
|
|
22
|
+
connectionString?: string
|
|
23
|
+
/** Optional logger; defaults to console.log. */
|
|
24
|
+
log?: (message: string) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Apply all pending platform migrations. Idempotent — already-applied files are skipped. */
|
|
28
|
+
export async function migrate(options: MigrateOptions = {}): Promise<{ applied: string[] }> {
|
|
29
|
+
const connectionString =
|
|
30
|
+
options.connectionString ??
|
|
31
|
+
process.env.GOVCORE_MIGRATE_DATABASE_URL ??
|
|
32
|
+
process.env.DATABASE_URL
|
|
33
|
+
if (!connectionString) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'govcore-migrate: set GOVCORE_MIGRATE_DATABASE_URL (owner role) or DATABASE_URL',
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
const log = options.log ?? ((m: string) => console.log(m))
|
|
39
|
+
const sql = postgres(connectionString, { max: 1 })
|
|
40
|
+
const applied: string[] = []
|
|
41
|
+
try {
|
|
42
|
+
await sql.unsafe('CREATE SCHEMA IF NOT EXISTS govcore')
|
|
43
|
+
await sql.unsafe(
|
|
44
|
+
`CREATE TABLE IF NOT EXISTS govcore.__govcore_migrations (
|
|
45
|
+
name text PRIMARY KEY,
|
|
46
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
47
|
+
)`,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
const done = new Set(
|
|
51
|
+
(await sql`SELECT name FROM govcore.__govcore_migrations`).map((r) => r.name as string),
|
|
52
|
+
)
|
|
53
|
+
const files = readdirSync(MIGRATIONS_DIR)
|
|
54
|
+
.filter((f) => f.endsWith('.sql'))
|
|
55
|
+
.sort()
|
|
56
|
+
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
if (done.has(file)) continue
|
|
59
|
+
const ddl = readFileSync(join(MIGRATIONS_DIR, file), 'utf8')
|
|
60
|
+
await sql.begin(async (tx) => {
|
|
61
|
+
await tx.unsafe(ddl)
|
|
62
|
+
await tx`INSERT INTO govcore.__govcore_migrations (name) VALUES (${file})`
|
|
63
|
+
})
|
|
64
|
+
applied.push(file)
|
|
65
|
+
log(`govcore-migrate: applied ${file}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
log(
|
|
69
|
+
applied.length
|
|
70
|
+
? `govcore-migrate: applied ${applied.length} migration(s)`
|
|
71
|
+
: 'govcore-migrate: already up to date',
|
|
72
|
+
)
|
|
73
|
+
return { applied }
|
|
74
|
+
} finally {
|
|
75
|
+
await sql.end()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Run when invoked as the `govcore-migrate` bin.
|
|
80
|
+
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
|
81
|
+
migrate().catch((err) => {
|
|
82
|
+
console.error(err)
|
|
83
|
+
process.exit(1)
|
|
84
|
+
})
|
|
85
|
+
}
|