anbaric-cloud-hosting 1.42.0 → 1.44.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 +7 -0
- package/package.json +7 -7
- package/src/auth/CliKey.ts +7 -1
- package/src/auth/HttpCliKeyStore.ts +5 -2
- package/src/auth/HttpMembershipService.ts +38 -12
- package/src/auth/InMemoryMembershipService.ts +16 -4
- package/src/auth/MembershipService.ts +12 -7
- package/src/auth/SessionSigner.ts +5 -2
- package/src/auth/TenantRole.ts +32 -0
- package/src/auth/TokenAuthenticator.ts +1 -1
- package/src/auth/User.ts +7 -1
- package/src/data-store/Migrations.ts +74 -0
- package/src/data-store/PlatformMigrations.ts +209 -0
- package/src/data-store/Schema.ts +29 -262
- package/src/hosting/HostingServer.ts +1 -1
- package/src/hosting/handlers/AppsHandler.ts +11 -0
- package/src/hosting/handlers/auth/AuthorizeCliHandler.ts +9 -1
- package/src/hosting/handlers/auth/InvitationsHandler.ts +22 -5
- package/src/hosting/handlers/auth/WhoamiHandler.ts +1 -0
- package/src/hosting/middleware/AuthenticationMiddleware.ts +26 -1
- package/src/hosting/pages/platform-ui.html +2 -2
- package/src/index.ts +1 -0
package/src/data-store/Schema.ts
CHANGED
|
@@ -1,273 +1,40 @@
|
|
|
1
1
|
import {Pool} from "pg";
|
|
2
|
+
import {migrate} from "./Migrations";
|
|
3
|
+
import {PLATFORM_MIGRATIONS} from "./PlatformMigrations";
|
|
2
4
|
|
|
3
|
-
/*
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
/* Brings the database up to date at boot, then settles the app's SQL role.
|
|
6
|
+
The role is not a migration: its password comes from the environment and may
|
|
7
|
+
be rotated, so it is re-applied every time rather than recorded as done. */
|
|
6
8
|
const ensureSchema = async (pool : Pool) : Promise<void> => {
|
|
7
|
-
await pool
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
workflow_id TEXT,
|
|
13
|
-
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
14
|
-
started_by TEXT NOT NULL DEFAULT 'system',
|
|
15
|
-
last_updated TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
16
|
-
transitions JSONB NOT NULL DEFAULT '[]',
|
|
17
|
-
status TEXT NOT NULL DEFAULT 'active',
|
|
18
|
-
inserted_at BIGINT GENERATED ALWAYS AS IDENTITY
|
|
19
|
-
)
|
|
20
|
-
`);
|
|
21
|
-
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS workflow_id TEXT");
|
|
22
|
-
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ NOT NULL DEFAULT now()");
|
|
23
|
-
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS started_by TEXT NOT NULL DEFAULT 'system'");
|
|
24
|
-
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS last_updated TIMESTAMPTZ NOT NULL DEFAULT now()");
|
|
25
|
-
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS transitions JSONB NOT NULL DEFAULT '[]'");
|
|
26
|
-
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS killed BOOLEAN NOT NULL DEFAULT false");
|
|
27
|
-
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'");
|
|
28
|
-
// A workflow is keyed by the composite (app_id, workflow_id). Older rows
|
|
29
|
-
// stored "appId/workflowId" in workflow_id; split that once into the two
|
|
30
|
-
// columns (best effort - a bare workflow id with no app is left as-is).
|
|
31
|
-
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS app_id TEXT");
|
|
32
|
-
await pool.query(`UPDATE jobs
|
|
33
|
-
SET app_id = split_part(workflow_id, '/', 1),
|
|
34
|
-
workflow_id = substring(workflow_id from position('/' in workflow_id) + 1)
|
|
35
|
-
WHERE app_id IS NULL AND workflow_id LIKE '%/%'`);
|
|
36
|
-
// The await a job is parked on; its metadata is normalised here rather than on the job.
|
|
37
|
-
await pool.query(`
|
|
38
|
-
CREATE TABLE IF NOT EXISTS awaits (
|
|
39
|
-
id UUID PRIMARY KEY,
|
|
40
|
-
metadata JSONB NOT NULL DEFAULT '{}',
|
|
41
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
42
|
-
)
|
|
43
|
-
`);
|
|
44
|
-
await pool.query("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS waiting_for UUID REFERENCES awaits(id) ON DELETE SET NULL");
|
|
45
|
-
await pool.query(`
|
|
46
|
-
CREATE TABLE IF NOT EXISTS documents (
|
|
47
|
-
app_id TEXT NOT NULL DEFAULT '',
|
|
48
|
-
collection TEXT NOT NULL,
|
|
49
|
-
id TEXT NOT NULL,
|
|
50
|
-
document JSONB NOT NULL,
|
|
51
|
-
inserted_at BIGINT GENERATED ALWAYS AS IDENTITY,
|
|
52
|
-
PRIMARY KEY (app_id, collection, id)
|
|
53
|
-
)
|
|
54
|
-
`);
|
|
55
|
-
// Documents are owned by an app: widen the key to (app_id, collection, id).
|
|
56
|
-
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS app_id TEXT NOT NULL DEFAULT ''");
|
|
57
|
-
await pool.query(`DO $$ BEGIN
|
|
58
|
-
IF NOT EXISTS (SELECT 1 FROM information_schema.key_column_usage
|
|
59
|
-
WHERE table_name = 'documents' AND constraint_name = 'documents_pkey'
|
|
60
|
-
AND column_name = 'app_id') THEN
|
|
61
|
-
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_pkey;
|
|
62
|
-
ALTER TABLE documents ADD PRIMARY KEY (app_id, collection, id);
|
|
63
|
-
END IF;
|
|
64
|
-
END $$`);
|
|
65
|
-
await pool.query(`
|
|
66
|
-
CREATE TABLE IF NOT EXISTS queue (
|
|
67
|
-
position BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
68
|
-
job_id TEXT NOT NULL,
|
|
69
|
-
workflow_id TEXT NOT NULL,
|
|
70
|
-
due TIMESTAMPTZ,
|
|
71
|
-
leased_until TIMESTAMPTZ
|
|
72
|
-
)
|
|
73
|
-
`);
|
|
74
|
-
await pool.query("ALTER TABLE queue ADD COLUMN IF NOT EXISTS app_id TEXT");
|
|
75
|
-
await pool.query("ALTER TABLE queue ADD COLUMN IF NOT EXISTS retry_at TIMESTAMPTZ");
|
|
76
|
-
await pool.query("ALTER TABLE queue ADD COLUMN IF NOT EXISTS attempts INT NOT NULL DEFAULT 0");
|
|
77
|
-
await pool.query(`UPDATE queue
|
|
78
|
-
SET app_id = split_part(workflow_id, '/', 1),
|
|
79
|
-
workflow_id = substring(workflow_id from position('/' in workflow_id) + 1)
|
|
80
|
-
WHERE app_id IS NULL AND workflow_id LIKE '%/%'`);
|
|
81
|
-
// Runs a scheduler has planned. Unique on (app_id, workflow_id, run_at) so
|
|
82
|
-
// two schedulers planning the same window converge on one row rather than
|
|
83
|
-
// starting a machine twice.
|
|
84
|
-
await pool.query(`
|
|
85
|
-
CREATE TABLE IF NOT EXISTS job_run_schedule (
|
|
86
|
-
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
87
|
-
app_id TEXT NOT NULL DEFAULT '',
|
|
88
|
-
workflow_id TEXT NOT NULL,
|
|
89
|
-
run_at TIMESTAMPTZ NOT NULL,
|
|
90
|
-
claimed_at TIMESTAMPTZ
|
|
91
|
-
)
|
|
92
|
-
`);
|
|
93
|
-
await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS job_run_schedule_run
|
|
94
|
-
ON job_run_schedule (app_id, workflow_id, run_at)`);
|
|
95
|
-
await pool.query(`CREATE INDEX IF NOT EXISTS job_run_schedule_due
|
|
96
|
-
ON job_run_schedule (run_at) WHERE claimed_at IS NULL`);
|
|
97
|
-
// User documentation the platform generates from an app's source on deploy,
|
|
98
|
-
// keyed by (app_id, slug) and held as a shallow tree: a root doc has a null
|
|
99
|
-
// parent_slug, a child names its parent's slug, and position orders
|
|
100
|
-
// siblings. A redeploy replaces the whole set for an app.
|
|
101
|
-
await pool.query(`
|
|
102
|
-
CREATE TABLE IF NOT EXISTS app_docs (
|
|
103
|
-
app_id TEXT NOT NULL,
|
|
104
|
-
slug TEXT NOT NULL,
|
|
105
|
-
title TEXT NOT NULL DEFAULT '',
|
|
106
|
-
markdown TEXT NOT NULL,
|
|
107
|
-
parent_slug TEXT,
|
|
108
|
-
position INTEGER NOT NULL DEFAULT 0,
|
|
109
|
-
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
110
|
-
PRIMARY KEY (app_id, slug)
|
|
111
|
-
)
|
|
112
|
-
`);
|
|
113
|
-
await pool.query("ALTER TABLE app_docs ADD COLUMN IF NOT EXISTS parent_slug TEXT");
|
|
114
|
-
await pool.query("ALTER TABLE app_docs ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0");
|
|
115
|
-
await pool.query(`
|
|
116
|
-
CREATE TABLE IF NOT EXISTS entitlements (
|
|
117
|
-
app_id TEXT,
|
|
118
|
-
entitlement_id TEXT NOT NULL,
|
|
119
|
-
notes TEXT NOT NULL DEFAULT '',
|
|
120
|
-
registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
121
|
-
)
|
|
122
|
-
`);
|
|
123
|
-
await pool.query(`
|
|
124
|
-
CREATE UNIQUE INDEX IF NOT EXISTS entitlements_key
|
|
125
|
-
ON entitlements (COALESCE(app_id, ''), entitlement_id)
|
|
126
|
-
`);
|
|
127
|
-
await pool.query(`
|
|
128
|
-
CREATE TABLE IF NOT EXISTS entitlement_grants (
|
|
129
|
-
id TEXT PRIMARY KEY,
|
|
130
|
-
user_id TEXT NOT NULL,
|
|
131
|
-
app_id TEXT,
|
|
132
|
-
entitlement_id TEXT NOT NULL,
|
|
133
|
-
notes TEXT NOT NULL DEFAULT '',
|
|
134
|
-
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
135
|
-
granted_by TEXT NOT NULL
|
|
136
|
-
)
|
|
137
|
-
`);
|
|
138
|
-
await pool.query(`
|
|
139
|
-
CREATE INDEX IF NOT EXISTS entitlement_grants_lookup
|
|
140
|
-
ON entitlement_grants (user_id, entitlement_id)
|
|
141
|
-
`);
|
|
142
|
-
await pool.query(`
|
|
143
|
-
INSERT INTO entitlements (app_id, entitlement_id, notes)
|
|
144
|
-
VALUES (NULL, 'access', 'Global access')
|
|
145
|
-
ON CONFLICT (COALESCE(app_id, ''), entitlement_id) DO NOTHING
|
|
146
|
-
`);
|
|
147
|
-
await pool.query(`
|
|
148
|
-
CREATE TABLE IF NOT EXISTS prompts (
|
|
149
|
-
app_id TEXT NOT NULL,
|
|
150
|
-
prompt_id TEXT NOT NULL,
|
|
151
|
-
version INTEGER NOT NULL,
|
|
152
|
-
instructions TEXT NOT NULL,
|
|
153
|
-
output_schema JSONB,
|
|
154
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
155
|
-
PRIMARY KEY (app_id, prompt_id, version)
|
|
156
|
-
)
|
|
157
|
-
`);
|
|
158
|
-
await pool.query("ALTER TABLE prompts DROP COLUMN IF EXISTS input_schema");
|
|
159
|
-
await pool.query("CREATE SCHEMA IF NOT EXISTS anbaric_system");
|
|
160
|
-
await pool.query(`
|
|
161
|
-
CREATE TABLE IF NOT EXISTS anbaric_system.cli_keys (
|
|
162
|
-
id TEXT PRIMARY KEY,
|
|
163
|
-
user_id TEXT NOT NULL,
|
|
164
|
-
client_name TEXT NOT NULL,
|
|
165
|
-
public_key TEXT NOT NULL,
|
|
166
|
-
tenant TEXT,
|
|
167
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
168
|
-
)
|
|
169
|
-
`);
|
|
170
|
-
await pool.query("ALTER TABLE anbaric_system.cli_keys ADD COLUMN IF NOT EXISTS tenant TEXT");
|
|
171
|
-
await pool.query(`
|
|
172
|
-
CREATE TABLE IF NOT EXISTS anbaric_system.users (
|
|
173
|
-
id TEXT PRIMARY KEY,
|
|
174
|
-
name TEXT NOT NULL DEFAULT '',
|
|
175
|
-
email TEXT NOT NULL DEFAULT '',
|
|
176
|
-
picture TEXT,
|
|
177
|
-
first_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
178
|
-
last_seen TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
179
|
-
)
|
|
180
|
-
`);
|
|
181
|
-
await pool.query(`
|
|
182
|
-
DO $$ BEGIN
|
|
183
|
-
CREATE TYPE anbaric_system.audit_interaction AS ENUM
|
|
184
|
-
('CREATE', 'UPDATE_PROPERTIES', 'CHANGE_STATE', 'DELETE', 'READ', 'LIST');
|
|
185
|
-
EXCEPTION WHEN duplicate_object THEN null;
|
|
186
|
-
END $$
|
|
187
|
-
`);
|
|
188
|
-
await pool.query(`
|
|
189
|
-
CREATE TABLE IF NOT EXISTS anbaric_system.audit_records (
|
|
190
|
-
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
191
|
-
resource_type TEXT NOT NULL,
|
|
192
|
-
resource_id TEXT NOT NULL,
|
|
193
|
-
actor_id TEXT NOT NULL,
|
|
194
|
-
actor_type TEXT NOT NULL,
|
|
195
|
-
interaction TEXT[] NOT NULL,
|
|
196
|
-
description TEXT NOT NULL,
|
|
197
|
-
details JSONB,
|
|
198
|
-
at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
199
|
-
)
|
|
200
|
-
`);
|
|
201
|
-
await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS resource_type TEXT");
|
|
202
|
-
await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS resource_id TEXT");
|
|
203
|
-
// The app that owns the audited resource; part of every resource's composite identity.
|
|
204
|
-
await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS app_id TEXT");
|
|
205
|
-
await pool.query("ALTER TABLE anbaric_system.audit_records ADD COLUMN IF NOT EXISTS interaction TEXT[]");
|
|
206
|
-
await pool.query(`
|
|
207
|
-
DO $$ BEGIN
|
|
208
|
-
IF EXISTS (SELECT 1 FROM information_schema.columns
|
|
209
|
-
WHERE table_schema = 'anbaric_system' AND table_name = 'audit_records'
|
|
210
|
-
AND column_name = 'interaction' AND data_type <> 'ARRAY') THEN
|
|
211
|
-
ALTER TABLE anbaric_system.audit_records
|
|
212
|
-
ALTER COLUMN interaction TYPE anbaric_system.audit_interaction[] USING ARRAY[interaction];
|
|
213
|
-
END IF;
|
|
214
|
-
END $$
|
|
215
|
-
`);
|
|
216
|
-
await pool.query(`
|
|
217
|
-
DO $$ BEGIN
|
|
218
|
-
IF EXISTS (SELECT 1 FROM information_schema.columns
|
|
219
|
-
WHERE table_schema = 'anbaric_system' AND table_name = 'audit_records'
|
|
220
|
-
AND column_name = 'job_id') THEN
|
|
221
|
-
UPDATE anbaric_system.audit_records
|
|
222
|
-
SET resource_type = COALESCE(resource_type, 'job'),
|
|
223
|
-
resource_id = COALESCE(resource_id, job_id),
|
|
224
|
-
interaction = COALESCE(interaction, ARRAY[change::text::anbaric_system.audit_interaction])
|
|
225
|
-
WHERE resource_id IS NULL OR interaction IS NULL;
|
|
226
|
-
ALTER TABLE anbaric_system.audit_records DROP COLUMN job_id;
|
|
227
|
-
ALTER TABLE anbaric_system.audit_records DROP COLUMN change;
|
|
228
|
-
END IF;
|
|
229
|
-
END $$
|
|
230
|
-
`);
|
|
231
|
-
await pool.query(`
|
|
232
|
-
DO $$ BEGIN
|
|
233
|
-
IF EXISTS (SELECT 1 FROM information_schema.columns
|
|
234
|
-
WHERE table_schema = 'anbaric_system' AND table_name = 'audit_records'
|
|
235
|
-
AND column_name = 'interaction' AND udt_name = '_audit_interaction') THEN
|
|
236
|
-
ALTER TABLE anbaric_system.audit_records
|
|
237
|
-
ALTER COLUMN interaction TYPE TEXT[] USING interaction::text[];
|
|
238
|
-
END IF;
|
|
239
|
-
END $$
|
|
240
|
-
`);
|
|
241
|
-
await pool.query("UPDATE anbaric_system.audit_records SET resource_type = 'job' WHERE resource_type IS NULL");
|
|
242
|
-
await pool.query("UPDATE anbaric_system.audit_records SET resource_id = 'unknown' WHERE resource_id IS NULL");
|
|
243
|
-
await pool.query("UPDATE anbaric_system.audit_records SET interaction = ARRAY['UPDATE_PROPERTIES']::text[] WHERE interaction IS NULL");
|
|
244
|
-
await pool.query("ALTER TABLE anbaric_system.audit_records ALTER COLUMN resource_type SET NOT NULL");
|
|
245
|
-
await pool.query("ALTER TABLE anbaric_system.audit_records ALTER COLUMN resource_id SET NOT NULL");
|
|
246
|
-
await pool.query("ALTER TABLE anbaric_system.audit_records ALTER COLUMN interaction SET NOT NULL");
|
|
247
|
-
await pool.query("CREATE INDEX IF NOT EXISTS audit_records_resource ON anbaric_system.audit_records (resource_type, resource_id)");
|
|
9
|
+
const applied = await migrate(pool, PLATFORM_MIGRATIONS);
|
|
10
|
+
if (applied.length > 0) console.log(`Applied ${applied.length} migration(s): ${applied.join(", ")}`);
|
|
11
|
+
|
|
12
|
+
await ensureAppRole(pool);
|
|
13
|
+
};
|
|
248
14
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
15
|
+
/* The shared SQL store for apps: a dedicated schema plus a login role scoped
|
|
16
|
+
to it, so deployed apps reach only anbaric_app_data - never the platform's
|
|
17
|
+
public (jobs/documents/queue) or anbaric_system schemas. */
|
|
18
|
+
const ensureAppRole = async (pool : Pool) : Promise<void> => {
|
|
252
19
|
const appSqlSchema = process.env.ANBARIC_SQL_SCHEMA ?? "anbaric_app_data";
|
|
253
|
-
if (
|
|
20
|
+
if (! /^[a-z_][a-z0-9_]*$/i.test(appSqlSchema)) throw new Error(`Invalid ANBARIC_SQL_SCHEMA "${appSqlSchema}"`);
|
|
254
21
|
await pool.query(`CREATE SCHEMA IF NOT EXISTS ${appSqlSchema}`);
|
|
255
22
|
|
|
256
23
|
const appDbPassword = process.env.ANBARIC_APP_DB_PASSWORD;
|
|
257
|
-
if (appDbPassword)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
24
|
+
if (! appDbPassword) return;
|
|
25
|
+
|
|
26
|
+
await pool.query(`DO $$ BEGIN
|
|
27
|
+
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anbaric_app') THEN CREATE ROLE anbaric_app LOGIN; END IF;
|
|
28
|
+
END $$`);
|
|
29
|
+
await pool.query(`ALTER ROLE anbaric_app LOGIN PASSWORD '${appDbPassword.replace(/'/g, "''")}'`);
|
|
30
|
+
await pool.query(`ALTER ROLE anbaric_app SET search_path TO ${appSqlSchema}`);
|
|
31
|
+
await pool.query(`GRANT USAGE, CREATE ON SCHEMA ${appSqlSchema} TO anbaric_app`);
|
|
32
|
+
await pool.query(`GRANT ALL ON ALL TABLES IN SCHEMA ${appSqlSchema} TO anbaric_app`);
|
|
33
|
+
await pool.query(`GRANT ALL ON ALL SEQUENCES IN SCHEMA ${appSqlSchema} TO anbaric_app`);
|
|
34
|
+
await pool.query(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${appSqlSchema} GRANT ALL ON TABLES TO anbaric_app`);
|
|
35
|
+
await pool.query(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${appSqlSchema} GRANT ALL ON SEQUENCES TO anbaric_app`);
|
|
36
|
+
await pool.query("REVOKE ALL ON SCHEMA public FROM anbaric_app");
|
|
37
|
+
await pool.query("REVOKE ALL ON SCHEMA anbaric_system FROM anbaric_app");
|
|
271
38
|
};
|
|
272
39
|
|
|
273
40
|
export { ensureSchema }
|
|
@@ -141,7 +141,7 @@ class HostingServer {
|
|
|
141
141
|
|
|
142
142
|
this.publicServer = new Server(publicRouter, [
|
|
143
143
|
new SessionMiddleware(),
|
|
144
|
-
new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory),
|
|
144
|
+
new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory, memberships),
|
|
145
145
|
]);
|
|
146
146
|
this.internalServer = new Server(internalRouter);
|
|
147
147
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {BuildLayer} from "../../app-management/BuildLayer";
|
|
2
|
+
import {deniesBuild} from "../../auth/TenantRole";
|
|
2
3
|
import {Request} from "../Request";
|
|
3
4
|
import {RequestHandler} from "../RequestHandler";
|
|
4
5
|
|
|
@@ -7,6 +8,11 @@ class AppsHandler implements RequestHandler {
|
|
|
7
8
|
constructor(private buildLayer : BuildLayer) {}
|
|
8
9
|
|
|
9
10
|
async handle(request : Request) : Promise<void> {
|
|
11
|
+
// Changing what is deployed is a builder's job; a USER may look.
|
|
12
|
+
if (this.changesApps(request) && deniesBuild(request.user?.tenantRole)) {
|
|
13
|
+
return request.reply(403, { error: "Your role in this tenant cannot deploy or remove apps" });
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
await this.buildLayer.ensureHydrated();
|
|
11
17
|
switch (request.subresource) {
|
|
12
18
|
case "deploy":
|
|
@@ -25,6 +31,11 @@ class AppsHandler implements RequestHandler {
|
|
|
25
31
|
request.notFound();
|
|
26
32
|
}
|
|
27
33
|
|
|
34
|
+
private changesApps(request : Request) : boolean {
|
|
35
|
+
if (request.subresource === "deploy" || request.subresource === "docs") return true;
|
|
36
|
+
return request.subresource === undefined && request.method === "DELETE";
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
private async handleDocs(request : Request, appName : string) : Promise<void> {
|
|
29
40
|
if (request.method !== "POST") return request.notFound();
|
|
30
41
|
try {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {CliAuthorizer} from "../../../auth/CliAuthorizer";
|
|
2
2
|
import {User} from "../../../auth/User";
|
|
3
|
+
import {deniesBuild} from "../../../auth/TenantRole";
|
|
3
4
|
import {Request} from "../../Request";
|
|
4
5
|
import {RequestHandler} from "../../RequestHandler";
|
|
5
6
|
import {PagesHandler} from "../PagesHandler";
|
|
@@ -40,8 +41,15 @@ class AuthorizeCliHandler implements RequestHandler {
|
|
|
40
41
|
if (typeof clientName !== "string" || clientName.trim().length === 0) {
|
|
41
42
|
return request.reply(400, { error: "Expected a body of { clientName : string }" });
|
|
42
43
|
}
|
|
44
|
+
// A key deploys as its owner, so only someone who may deploy may mint one.
|
|
45
|
+
if (deniesBuild(request.user?.tenantRole)) {
|
|
46
|
+
return request.reply(403, { error: "Your role in this tenant cannot create keys" });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The tenant is this platform's own: what it is deployed as, which is
|
|
50
|
+
// the slug the edge routes on and the CLI is later checked against.
|
|
43
51
|
await this.authorizer.approve(requestId, clientName.trim(), request.user ?? new User("local"),
|
|
44
|
-
|
|
52
|
+
this.tenant ?? request.tenant?.id);
|
|
45
53
|
request.reply(204);
|
|
46
54
|
}
|
|
47
55
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {MembershipService} from "../../../auth/MembershipService";
|
|
2
|
+
import {canInvite, isTenantRole} from "../../../auth/TenantRole";
|
|
2
3
|
import {Request} from "../../Request";
|
|
3
4
|
import {RequestHandler} from "../../RequestHandler";
|
|
4
5
|
|
|
@@ -11,22 +12,38 @@ class InvitationsHandler implements RequestHandler {
|
|
|
11
12
|
constructor(private memberships : MembershipService) {}
|
|
12
13
|
|
|
13
14
|
async handle(request : Request) : Promise<void> {
|
|
14
|
-
|
|
15
|
+
// Only a tenant's owner or admin administers its membership. Central
|
|
16
|
+
// checks this too - it is the authority - but refusing here keeps the
|
|
17
|
+
// console honest and gives a clear answer without a round trip.
|
|
18
|
+
if (! canInvite(request.user?.tenantRole)) {
|
|
19
|
+
return request.reply(403, { error: "Your role in this tenant cannot manage members" });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const asking = this.asking(request);
|
|
23
|
+
if (request.method === "GET" && ! request.id) return request.reply(200, await this.memberships.pending(asking));
|
|
15
24
|
if (request.method === "POST" && ! request.id) return this.invite(request);
|
|
16
25
|
if (request.method === "DELETE" && request.id && ! request.subresource) {
|
|
17
|
-
const revoked = await this.memberships.revoke(request.id);
|
|
26
|
+
const revoked = await this.memberships.revoke(request.id, asking);
|
|
18
27
|
return revoked ? request.reply(204) : request.notFound();
|
|
19
28
|
}
|
|
20
29
|
request.notFound();
|
|
21
30
|
}
|
|
22
31
|
|
|
32
|
+
private asking(request : Request) {
|
|
33
|
+
return request.user ? { id: request.user.id, name: request.user.name } : { id: "system" };
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
private async invite(request : Request) : Promise<void> {
|
|
24
|
-
const body = await request.body() as { email? : string } | undefined;
|
|
37
|
+
const body = await request.body() as { email? : string, role? : string } | undefined;
|
|
25
38
|
const email = String(body?.email ?? "").trim();
|
|
26
39
|
if (! EMAIL_SHAPE.test(email)) return request.reply(400, { error: "A valid email address is required" });
|
|
27
40
|
|
|
28
|
-
const
|
|
29
|
-
|
|
41
|
+
const role = body?.role ?? "USER";
|
|
42
|
+
if (! isTenantRole(role) || role === "OWNER") {
|
|
43
|
+
return request.reply(400, { error: "Invite someone as an ADMIN, BUILDER or USER" });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
request.reply(201, await this.memberships.invite(email, role, this.asking(request)));
|
|
30
47
|
}
|
|
31
48
|
|
|
32
49
|
}
|
|
@@ -3,6 +3,7 @@ import {SessionSigner} from "../../auth/SessionSigner";
|
|
|
3
3
|
import {TokenAuthenticator} from "../../auth/TokenAuthenticator";
|
|
4
4
|
import {User} from "../../auth/User";
|
|
5
5
|
import {UserDirectory} from "../../auth/UserDirectory";
|
|
6
|
+
import {MembershipService} from "../../auth/MembershipService";
|
|
6
7
|
import {Middleware} from "../Middleware";
|
|
7
8
|
import {Request} from "../Request";
|
|
8
9
|
|
|
@@ -26,7 +27,8 @@ class AuthenticationMiddleware implements Middleware {
|
|
|
26
27
|
private tokenAuthenticator? : TokenAuthenticator,
|
|
27
28
|
private isOpen : OpenRequestPredicate = () => false,
|
|
28
29
|
private sessionSigner : SessionSigner = new SessionSigner(),
|
|
29
|
-
private userDirectory? : UserDirectory
|
|
30
|
+
private userDirectory? : UserDirectory,
|
|
31
|
+
private memberships? : MembershipService) {}
|
|
30
32
|
|
|
31
33
|
async apply(request : Request) : Promise<boolean> {
|
|
32
34
|
if (this.isOpen(request)) return true;
|
|
@@ -35,6 +37,7 @@ class AuthenticationMiddleware implements Middleware {
|
|
|
35
37
|
const user = await this.tokenAuthenticator.authenticate(request.raw, request.rawResponse);
|
|
36
38
|
if (!user) return false;
|
|
37
39
|
request.user = user;
|
|
40
|
+
if (! await this.admitted(request)) return false;
|
|
38
41
|
return this.authorized(request);
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -42,6 +45,7 @@ class AuthenticationMiddleware implements Middleware {
|
|
|
42
45
|
const session = this.sessionSigner.verify(request.session);
|
|
43
46
|
if (session) {
|
|
44
47
|
[request.user, request.tenant] = session;
|
|
48
|
+
if (! await this.admitted(request)) return false;
|
|
45
49
|
this.sessionSigner.issue(request.rawResponse, request.user!, request.tenant);
|
|
46
50
|
await this.remember(request.user!);
|
|
47
51
|
return this.authorized(request);
|
|
@@ -63,6 +67,7 @@ class AuthenticationMiddleware implements Middleware {
|
|
|
63
67
|
if (!authenticated) return false;
|
|
64
68
|
|
|
65
69
|
[request.user, request.tenant] = authenticated;
|
|
70
|
+
if (! await this.admitted(request)) return false;
|
|
66
71
|
if (this.sessionSigner.configured && !request.handled) {
|
|
67
72
|
this.sessionSigner.issue(request.rawResponse, request.user!, request.tenant);
|
|
68
73
|
}
|
|
@@ -70,6 +75,26 @@ class AuthenticationMiddleware implements Middleware {
|
|
|
70
75
|
return this.authorized(request);
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
/* Membership in this tenant is what admits a browser session at all, and
|
|
79
|
+
the role it carries is what the handlers gate on. A platform with no
|
|
80
|
+
membership service - a local or unmanaged install - admits everyone, as
|
|
81
|
+
it always has. Central being unreachable must not lock the tenant out,
|
|
82
|
+
so a lookup that throws is left to the handlers to refuse. */
|
|
83
|
+
private async admitted(request : Request) : Promise<boolean> {
|
|
84
|
+
if (! this.memberships || ! request.user) return true;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
request.user.tenantRole = await this.memberships.roleFor(request.user.id);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error(`[membership] could not resolve a role for "${request.user.id}":`, error);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (request.user.tenantRole) return true;
|
|
94
|
+
if (! request.handled) request.reply(403, { error: "You are not a member of this tenant" });
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
73
98
|
private async remember(user : User) : Promise<void> {
|
|
74
99
|
if (! this.userDirectory) return;
|
|
75
100
|
|