@lamalibre/create-lamaste 4.4.1 → 4.5.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/package.json +2 -2
- package/scripts/lamaste-priv +200 -16
- package/src/index.js +1 -0
- package/src/lib/backend-cookie.js +65 -0
- package/src/lib/ip-panel-vhost.js +208 -0
- package/src/lib/service-config.js +5 -3
- package/src/tasks/nginx.js +18 -135
- package/src/tasks/redeploy.js +39 -1
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/authentication.md +1 -1
- package/vendor/docs/01-concepts/authorization.md +66 -48
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +70 -33
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +5 -5
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
- package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/managing-users.md +113 -25
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +3 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +165 -18
- package/vendor/docs/03-architecture/overview.md +13 -17
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +3 -2
- package/vendor/docs/03-architecture/state-management.md +4 -8
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +291 -22
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/gatekeeper.md +60 -5
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +14 -12
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/registry-and-oidc.md +3 -3
- package/vendor/docs/04-api-reference/tunnels.md +67 -29
- package/vendor/docs/04-api-reference/users.md +241 -45
- package/vendor/docs/05-operations/backup-and-restore.md +35 -26
- package/vendor/docs/05-operations/monitoring.md +39 -0
- package/vendor/docs/05-operations/uninstalling.md +2 -1
- package/vendor/docs/05-operations/upgrades.md +15 -6
- package/vendor/docs/06-reference/config-files.md +82 -188
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +25 -26
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/gatekeeper-design.md +10 -8
- package/vendor/docs/identity-directory-design.md +48 -14
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/index.d.ts +2 -1
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +2 -0
- package/vendor/gatekeeper/dist/index.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.d.ts +6 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.js +6 -1
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.d.ts +5 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/groups.js +5 -1
- package/vendor/gatekeeper/dist/lib/groups.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/principals.d.ts +29 -0
- package/vendor/gatekeeper/dist/lib/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/principals.js +67 -0
- package/vendor/gatekeeper/dist/lib/principals.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts +13 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js +68 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts +19 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +22 -48
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts +15 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +250 -103
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js +61 -4
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/principals.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js +27 -0
- package/vendor/gatekeeper/dist/server/routes/principals.js.map +1 -0
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +15 -1
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/plugin-identity.js +335 -0
- package/vendor/server/src/commands/plugins.js +99 -11
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/tunnels.js +102 -2
- package/vendor/server/src/commands/uninstall.js +3 -1
- package/vendor/server/src/commands/users.js +358 -0
- package/vendor/server/src/plugin-approval.js +107 -5
- package/vendor/server/src/plugin-identity.js +382 -0
- package/vendor/server-ui/dist/assets/index-D-fer5ef.css +1 -0
- package/vendor/server-ui/dist/assets/index-DSaDPJaw.js +84 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +25 -1
- package/vendor/serverd/src/lib/audience.js +576 -0
- package/vendor/serverd/src/lib/authelia.js +78 -7
- package/vendor/serverd/src/lib/config.js +68 -43
- package/vendor/serverd/src/lib/directory.js +1113 -0
- package/vendor/serverd/src/lib/entitlements.js +517 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +215 -10
- package/vendor/serverd/src/lib/hook-pipelines.js +13 -0
- package/vendor/serverd/src/lib/identity-sync.js +905 -0
- package/vendor/serverd/src/lib/migrations/state/0013_identity_directory.sql +158 -0
- package/vendor/serverd/src/lib/migrations/state/0014_identity_ledger.sql +20 -0
- package/vendor/serverd/src/lib/mtls.js +6 -2
- package/vendor/serverd/src/lib/nginx.js +279 -48
- package/vendor/serverd/src/lib/plugin-hooks.js +98 -45
- package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
- package/vendor/serverd/src/lib/plugin-lifecycle.js +100 -9
- package/vendor/serverd/src/lib/plugins.js +59 -4
- package/vendor/serverd/src/lib/registry-credential.js +9 -6
- package/vendor/serverd/src/lib/request-logging.js +19 -0
- package/vendor/serverd/src/lib/retained-data.js +50 -4
- package/vendor/serverd/src/lib/state.js +18 -0
- package/vendor/serverd/src/lib/tunnel-hooks.js +4 -1
- package/vendor/serverd/src/lib/user-access-session.js +21 -11
- package/vendor/serverd/src/lib/user-caller.js +67 -0
- package/vendor/serverd/src/lib/user-surface.js +733 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +136 -34
- package/vendor/serverd/src/middleware/user-access-session.js +9 -0
- package/vendor/serverd/src/routes/agent-forwarder.js +4 -2
- package/vendor/serverd/src/routes/invite.js +53 -12
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +107 -16
- package/vendor/serverd/src/routes/management/identity-directory.js +79 -0
- package/vendor/serverd/src/routes/management/invitations.js +78 -68
- package/vendor/serverd/src/routes/management/plugin-identity.js +221 -0
- package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
- package/vendor/serverd/src/routes/management/tunnels.js +98 -2
- package/vendor/serverd/src/routes/management/users.js +290 -208
- package/vendor/serverd/src/routes/management.js +4 -0
- package/vendor/serverd/src/routes/onboarding/provision.js +14 -1
- package/vendor/serverd/src/routes/user-access.js +17 -4
- package/vendor/serverd/src/routes/user-surface.js +69 -0
- package/vendor/server-ui/dist/assets/index-25wo_8av.css +0 -1
- package/vendor/server-ui/dist/assets/index-roDHz3dM.js +0 -84
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import { USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_REGEX } from '@lamalibre/lamaste';
|
|
4
|
+
import { readInvitations, withInvitationsLock, writeInvitations } from '../../lib/state.js';
|
|
4
5
|
import { readUsersRaw } from '../../lib/authelia.js';
|
|
5
6
|
import { getConfig } from '../../lib/config.js';
|
|
6
7
|
|
|
7
8
|
const IdParamSchema = z.object({ id: z.string().uuid() });
|
|
8
9
|
|
|
9
10
|
const CreateInvitationSchema = z.object({
|
|
11
|
+
// Same rule as the users API (core's USERNAME_REGEX). A leading "-" would
|
|
12
|
+
// read as a flag to Authelia's CLI when a TOTP secret is stored for the user.
|
|
10
13
|
username: z
|
|
11
14
|
.string()
|
|
12
|
-
.min(
|
|
13
|
-
.max(
|
|
15
|
+
.min(USERNAME_MIN_LENGTH)
|
|
16
|
+
.max(USERNAME_MAX_LENGTH)
|
|
14
17
|
.regex(
|
|
15
|
-
|
|
16
|
-
// Authelia's CLI when a TOTP secret is stored for the user.
|
|
17
|
-
/^[a-z0-9][a-z0-9_-]*$/,
|
|
18
|
+
USERNAME_REGEX,
|
|
18
19
|
'Username must start with a lowercase letter or digit and may contain underscores or hyphens',
|
|
19
20
|
),
|
|
20
21
|
email: z.string().email(),
|
|
@@ -67,50 +68,55 @@ export default async function invitationRoutes(fastify, _opts) {
|
|
|
67
68
|
return reply.code(409).send({ error: 'Username already exists as an Authelia user' });
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
invitations
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
// The pending-invitation check and the write hold the invitations
|
|
72
|
+
// lock: nothing else may rewrite the file between them.
|
|
73
|
+
const outcome = await withInvitationsLock(async () => {
|
|
74
|
+
let invitations;
|
|
75
|
+
try {
|
|
76
|
+
invitations = await readInvitations();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
request.log.error(err, 'Failed to read invitations');
|
|
79
|
+
return { status: 500, body: { error: 'Failed to read invitations' } };
|
|
80
|
+
}
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
const now = new Date();
|
|
83
|
+
const existingPending = invitations.find(
|
|
84
|
+
(inv) => inv.username === body.username && !inv.used && new Date(inv.expiresAt) > now,
|
|
85
|
+
);
|
|
86
|
+
if (existingPending) {
|
|
87
|
+
return {
|
|
88
|
+
status: 409,
|
|
89
|
+
body: { error: 'A pending invitation already exists for this username' },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const expiresAt = new Date(now.getTime() + body.expiresInDays * 24 * 60 * 60 * 1000);
|
|
94
|
+
const invitation = {
|
|
95
|
+
id: crypto.randomUUID(),
|
|
96
|
+
token: crypto.randomBytes(32).toString('hex'),
|
|
97
|
+
username: body.username,
|
|
98
|
+
email: body.email,
|
|
99
|
+
groups: body.groups,
|
|
100
|
+
createdAt: now.toISOString(),
|
|
101
|
+
expiresAt: expiresAt.toISOString(),
|
|
102
|
+
used: false,
|
|
103
|
+
acceptedAt: null,
|
|
104
|
+
};
|
|
105
|
+
invitations.push(invitation);
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await writeInvitations(invitations);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
request.log.error(err, 'Failed to write invitations');
|
|
111
|
+
return { status: 500, body: { error: 'Failed to save invitation' } };
|
|
112
|
+
}
|
|
113
|
+
return { status: 201, invitation };
|
|
114
|
+
});
|
|
115
|
+
if (outcome.status !== 201) return reply.code(outcome.status).send(outcome.body);
|
|
88
116
|
|
|
117
|
+
const { invitation } = outcome;
|
|
118
|
+
const { token } = invitation;
|
|
89
119
|
const config = getConfig();
|
|
90
|
-
const token = crypto.randomBytes(32).toString('hex');
|
|
91
|
-
const expiresAt = new Date(now.getTime() + body.expiresInDays * 24 * 60 * 60 * 1000);
|
|
92
|
-
|
|
93
|
-
const invitation = {
|
|
94
|
-
id: crypto.randomUUID(),
|
|
95
|
-
token,
|
|
96
|
-
username: body.username,
|
|
97
|
-
email: body.email,
|
|
98
|
-
groups: body.groups,
|
|
99
|
-
createdAt: now.toISOString(),
|
|
100
|
-
expiresAt: expiresAt.toISOString(),
|
|
101
|
-
used: false,
|
|
102
|
-
acceptedAt: null,
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
invitations.push(invitation);
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
await writeInvitations(invitations);
|
|
109
|
-
} catch (err) {
|
|
110
|
-
request.log.error(err, 'Failed to write invitations');
|
|
111
|
-
return reply.code(500).send({ error: 'Failed to save invitation' });
|
|
112
|
-
}
|
|
113
|
-
|
|
114
120
|
const inviteUrl = config.domain ? `https://auth.${config.domain}/invite/${token}` : null;
|
|
115
121
|
|
|
116
122
|
return reply.code(201).send({
|
|
@@ -138,27 +144,31 @@ export default async function invitationRoutes(fastify, _opts) {
|
|
|
138
144
|
async (request, reply) => {
|
|
139
145
|
const { id } = IdParamSchema.parse(request.params);
|
|
140
146
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
147
|
+
const outcome = await withInvitationsLock(async () => {
|
|
148
|
+
let invitations;
|
|
149
|
+
try {
|
|
150
|
+
invitations = await readInvitations();
|
|
151
|
+
} catch (err) {
|
|
152
|
+
request.log.error(err, 'Failed to read invitations');
|
|
153
|
+
return { status: 500, body: { error: 'Failed to read invitations' } };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const index = invitations.findIndex((inv) => inv.id === id);
|
|
157
|
+
if (index === -1) {
|
|
158
|
+
return { status: 404, body: { error: 'Invitation not found' } };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
invitations.splice(index, 1);
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await writeInvitations(invitations);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
request.log.error(err, 'Failed to write invitations');
|
|
167
|
+
return { status: 500, body: { error: 'Failed to revoke invitation' } };
|
|
168
|
+
}
|
|
169
|
+
return { status: 200 };
|
|
170
|
+
});
|
|
171
|
+
if (outcome.status !== 200) return reply.code(outcome.status).send(outcome.body);
|
|
162
172
|
|
|
163
173
|
return { ok: true };
|
|
164
174
|
},
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A plugin's users (slice 6): what its identity block declares, who is
|
|
3
|
+
* entitled to it with which roles, its audience as the agent half is told,
|
|
4
|
+
* and the feed that tells it. Admin only. Business logic lives in
|
|
5
|
+
* `lib/entitlements.js`, `lib/audience.js` and `lib/identity-sync.js`.
|
|
6
|
+
*
|
|
7
|
+
* GET /plugins/:name/identity declared block, approval, readiness,
|
|
8
|
+
* sync state, audience counts, pending erasures
|
|
9
|
+
* GET /plugins/:name/audience members and tombstones, in seq order
|
|
10
|
+
* GET /plugins/:name/entitlements role grants (and how many grants carry no roles)
|
|
11
|
+
* PUT /plugins/:name/entitlements { principalType, principalId, roles }
|
|
12
|
+
* DELETE /plugins/:name/entitlements?principalType=&principalId=
|
|
13
|
+
* POST /plugins/:name/identity/sync { reconcile? } — sync now
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_REGEX } from '@lamalibre/lamaste';
|
|
18
|
+
import { actorFromRequest } from '../../lib/actor.js';
|
|
19
|
+
import { listAudience } from '../../lib/audience.js';
|
|
20
|
+
import { listEntitlements, removeEntitlement, setEntitlement } from '../../lib/entitlements.js';
|
|
21
|
+
import { identityView, syncPluginIdentity } from '../../lib/identity-sync.js';
|
|
22
|
+
import { hookStatus } from '../../lib/plugin-hooks.js';
|
|
23
|
+
import { findPlugin } from '../../lib/plugins.js';
|
|
24
|
+
|
|
25
|
+
const PluginNameParamSchema = z.object({
|
|
26
|
+
name: z
|
|
27
|
+
.string()
|
|
28
|
+
.min(1)
|
|
29
|
+
.max(100)
|
|
30
|
+
.regex(/^[a-z0-9-]+$/, 'Plugin name must contain only lowercase letters, numbers, and hyphens'),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** A user principal is a username, a group principal a Lamaste group name (Gatekeeper's rule). */
|
|
34
|
+
const USER_PRINCIPAL = {
|
|
35
|
+
principalType: z.literal('user'),
|
|
36
|
+
principalId: z
|
|
37
|
+
.string()
|
|
38
|
+
.min(USERNAME_MIN_LENGTH)
|
|
39
|
+
.max(USERNAME_MAX_LENGTH)
|
|
40
|
+
.regex(USERNAME_REGEX, 'principalId is a username'),
|
|
41
|
+
};
|
|
42
|
+
const GROUP_PRINCIPAL = {
|
|
43
|
+
principalType: z.literal('group'),
|
|
44
|
+
principalId: z
|
|
45
|
+
.string()
|
|
46
|
+
.min(2)
|
|
47
|
+
.max(63)
|
|
48
|
+
.regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'principalId is a group name'),
|
|
49
|
+
};
|
|
50
|
+
/** Role ids are checked against the manifest by the library. */
|
|
51
|
+
const ROLES = z.array(z.string().min(1).max(32)).min(1).max(32);
|
|
52
|
+
|
|
53
|
+
const PrincipalSchema = z.discriminatedUnion('principalType', [
|
|
54
|
+
z.object(USER_PRINCIPAL).strict(),
|
|
55
|
+
z.object(GROUP_PRINCIPAL).strict(),
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const EntitlementBodySchema = z.discriminatedUnion('principalType', [
|
|
59
|
+
z.object({ ...USER_PRINCIPAL, roles: ROLES }).strict(),
|
|
60
|
+
z.object({ ...GROUP_PRINCIPAL, roles: ROLES }).strict(),
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
const SyncBodySchema = z.object({ reconcile: z.boolean().optional() }).strict();
|
|
64
|
+
|
|
65
|
+
/** The refusal a library threw (its `statusCode`, message and `code`), or a 500. */
|
|
66
|
+
function sendFailure(request, reply, err, fallback) {
|
|
67
|
+
if (Number.isInteger(err.statusCode)) {
|
|
68
|
+
return reply.code(err.statusCode).send({
|
|
69
|
+
error: err.message,
|
|
70
|
+
...(err.code ? { code: err.code } : {}),
|
|
71
|
+
...(Array.isArray(err.unknown) ? { unknown: err.unknown } : {}),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
request.log.error(err, fallback);
|
|
75
|
+
return reply.code(500).send({ error: fallback });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The installed plugin, answering 404 (not installed) or 400 `no_identity` (when it must declare identity). */
|
|
79
|
+
async function pluginFor(request, reply, { requireIdentity }) {
|
|
80
|
+
const { name } = PluginNameParamSchema.parse(request.params);
|
|
81
|
+
const plugin = await findPlugin(name);
|
|
82
|
+
if (!plugin) {
|
|
83
|
+
reply.code(404).send({ error: `Plugin "${name}" not found` });
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (requireIdentity && !plugin.identity) {
|
|
87
|
+
reply
|
|
88
|
+
.code(400)
|
|
89
|
+
.send({ error: `Plugin "${name}" declares no identity (roles)`, code: 'no_identity' });
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return plugin;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default async function pluginIdentityRoutes(fastify, _opts) {
|
|
96
|
+
fastify.get(
|
|
97
|
+
'/plugins/:name/identity',
|
|
98
|
+
{ preHandler: fastify.requireRole(['admin']) },
|
|
99
|
+
async (request, reply) => {
|
|
100
|
+
const plugin = await pluginFor(request, reply, { requireIdentity: false });
|
|
101
|
+
if (!plugin) return reply;
|
|
102
|
+
const [view, hooks] = await Promise.all([identityView(plugin.name), hookStatus(plugin)]);
|
|
103
|
+
return {
|
|
104
|
+
identity: {
|
|
105
|
+
...view,
|
|
106
|
+
approval: { approved: hooks.identity.approved, state: hooks.approval.state },
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
fastify.get(
|
|
113
|
+
'/plugins/:name/audience',
|
|
114
|
+
{ preHandler: fastify.requireRole(['admin']) },
|
|
115
|
+
async (request, reply) => {
|
|
116
|
+
const plugin = await pluginFor(request, reply, { requireIdentity: true });
|
|
117
|
+
if (!plugin) return reply;
|
|
118
|
+
const rows = await listAudience(plugin.name);
|
|
119
|
+
return {
|
|
120
|
+
head: rows.reduce((head, row) => Math.max(head, row.seq), 0),
|
|
121
|
+
audience: rows.map((row) => ({
|
|
122
|
+
userId: row.userId,
|
|
123
|
+
seq: row.seq,
|
|
124
|
+
version: row.version,
|
|
125
|
+
updatedAt: row.updatedAt,
|
|
126
|
+
change: row.change,
|
|
127
|
+
})),
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
fastify.get(
|
|
133
|
+
'/plugins/:name/entitlements',
|
|
134
|
+
{ preHandler: fastify.requireRole(['admin']) },
|
|
135
|
+
async (request, reply) => {
|
|
136
|
+
const plugin = await pluginFor(request, reply, { requireIdentity: true });
|
|
137
|
+
if (!plugin) return reply;
|
|
138
|
+
try {
|
|
139
|
+
return await listEntitlements(plugin);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return sendFailure(request, reply, err, 'Failed to read the entitlements');
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
fastify.put(
|
|
147
|
+
'/plugins/:name/entitlements',
|
|
148
|
+
{
|
|
149
|
+
preHandler: fastify.requireRole(['admin']),
|
|
150
|
+
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
|
|
151
|
+
},
|
|
152
|
+
async (request, reply) => {
|
|
153
|
+
const plugin = await pluginFor(request, reply, { requireIdentity: true });
|
|
154
|
+
if (!plugin) return reply;
|
|
155
|
+
const body = EntitlementBodySchema.parse(request.body);
|
|
156
|
+
try {
|
|
157
|
+
const result = await setEntitlement(plugin, body, {
|
|
158
|
+
actor: actorFromRequest(request),
|
|
159
|
+
logger: request.log,
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
entitlement: result.entitlement,
|
|
164
|
+
changed: result.changed,
|
|
165
|
+
...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
|
166
|
+
};
|
|
167
|
+
} catch (err) {
|
|
168
|
+
return sendFailure(request, reply, err, 'Failed to set the entitlement');
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
fastify.delete(
|
|
174
|
+
'/plugins/:name/entitlements',
|
|
175
|
+
{
|
|
176
|
+
preHandler: fastify.requireRole(['admin']),
|
|
177
|
+
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
|
|
178
|
+
},
|
|
179
|
+
async (request, reply) => {
|
|
180
|
+
const plugin = await pluginFor(request, reply, { requireIdentity: true });
|
|
181
|
+
if (!plugin) return reply;
|
|
182
|
+
const principal = PrincipalSchema.parse(request.query ?? {});
|
|
183
|
+
try {
|
|
184
|
+
const result = await removeEntitlement(plugin, principal, {
|
|
185
|
+
actor: actorFromRequest(request),
|
|
186
|
+
logger: request.log,
|
|
187
|
+
});
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
revoked: result.revoked,
|
|
191
|
+
...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
|
192
|
+
};
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return sendFailure(request, reply, err, 'Failed to remove the entitlement');
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
fastify.post(
|
|
200
|
+
'/plugins/:name/identity/sync',
|
|
201
|
+
{
|
|
202
|
+
preHandler: fastify.requireRole(['admin']),
|
|
203
|
+
config: { rateLimit: { max: 10, timeWindow: '1 minute' } },
|
|
204
|
+
},
|
|
205
|
+
async (request, reply) => {
|
|
206
|
+
const plugin = await pluginFor(request, reply, { requireIdentity: true });
|
|
207
|
+
if (!plugin) return reply;
|
|
208
|
+
const body = SyncBodySchema.parse(request.body ?? {});
|
|
209
|
+
const result = await syncPluginIdentity(plugin.name, {
|
|
210
|
+
reconcile: body.reconcile === true,
|
|
211
|
+
logger: request.log,
|
|
212
|
+
});
|
|
213
|
+
const view = await identityView(plugin.name);
|
|
214
|
+
return {
|
|
215
|
+
ok: result.status === 'synced' || result.status === 'up-to-date',
|
|
216
|
+
result,
|
|
217
|
+
sync: view?.sync ?? null,
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* GET /api/registry-credential what is configured — never a token
|
|
5
5
|
* PUT /api/registry-credential store a credential after verifying the
|
|
6
6
|
* whole chain against the registry
|
|
7
|
-
* (admin
|
|
7
|
+
* (read + admin scopes required)
|
|
8
8
|
* DELETE /api/registry-credential remove it
|
|
9
9
|
*
|
|
10
10
|
* `auth.kind` is `token` (a pasted long-lived Feria token) or `oidc` (the
|
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
* under the tunnel-state lock. Read-only endpoints and HTTP-specific auth
|
|
6
6
|
* checks remain here; checks that depend on the tunnel's current state run
|
|
7
7
|
* inside the lock through the workflows' `authorize` hook.
|
|
8
|
+
*
|
|
9
|
+
* A tunnel may serve agent-hosted plugins' user surfaces (slice 6, admin
|
|
10
|
+
* only; `lib/user-surface.js`):
|
|
11
|
+
*
|
|
12
|
+
* PUT /tunnels/:id/user-surface { route } — bind
|
|
13
|
+
* DELETE /tunnels/:id/user-surface/:route unbind
|
|
14
|
+
*
|
|
15
|
+
* Listings carry the binding (`userSurface: { routes }`) and how each bound
|
|
16
|
+
* route is served (`userSurfaceStatus`).
|
|
8
17
|
*/
|
|
9
18
|
import { z } from 'zod';
|
|
10
19
|
import {
|
|
@@ -30,6 +39,14 @@ import { buildChiselArgs } from '../../lib/chisel-args.js';
|
|
|
30
39
|
import { listAgentPluginAssignments } from '../../lib/plugin-hosting.js';
|
|
31
40
|
import { pendingAgentJobs } from '../../lib/agent-jobs.js';
|
|
32
41
|
import { recordAgentContact } from '../../lib/agent-presence.js';
|
|
42
|
+
import { actorFromRequest } from '../../lib/actor.js';
|
|
43
|
+
import {
|
|
44
|
+
auditDeletedTunnelUserSurface,
|
|
45
|
+
bindTunnelUserSurface,
|
|
46
|
+
unbindTunnelUserSurface,
|
|
47
|
+
userSurfaceDeps,
|
|
48
|
+
withUserSurfaceStatus,
|
|
49
|
+
} from '../../lib/user-surface.js';
|
|
33
50
|
import {
|
|
34
51
|
tunnelNginxDeps,
|
|
35
52
|
tunnelCertbotDeps,
|
|
@@ -39,6 +56,20 @@ import {
|
|
|
39
56
|
|
|
40
57
|
const IdParamSchema = z.object({ id: z.string().uuid() });
|
|
41
58
|
|
|
59
|
+
/** A plugin route, as a user surface is bound for it (core `isUserSurfaceRoute` decides the rest). */
|
|
60
|
+
const UserSurfaceRouteSchema = z
|
|
61
|
+
.string()
|
|
62
|
+
.min(1)
|
|
63
|
+
.max(64)
|
|
64
|
+
.regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'route must be a plugin route');
|
|
65
|
+
|
|
66
|
+
const UserSurfaceBodySchema = z.object({ route: UserSurfaceRouteSchema }).strict();
|
|
67
|
+
|
|
68
|
+
const UserSurfaceParamsSchema = z.object({ id: z.string().uuid(), route: UserSurfaceRouteSchema });
|
|
69
|
+
|
|
70
|
+
/** Binding and unbinding rewrite a vhost and reload nginx. */
|
|
71
|
+
const USER_SURFACE_WRITE_RATE_LIMIT = { rateLimit: { max: 30, timeWindow: '1 minute' } };
|
|
72
|
+
|
|
42
73
|
const TunnelPortSchema = z
|
|
43
74
|
.number()
|
|
44
75
|
.int('Port must be an integer')
|
|
@@ -215,11 +246,30 @@ function tunnelErrorStatus(code) {
|
|
|
215
246
|
return 403;
|
|
216
247
|
case 'NOT_FOUND':
|
|
217
248
|
return 404;
|
|
249
|
+
case 'USER_SURFACE_CONFLICT':
|
|
250
|
+
return 409;
|
|
218
251
|
default:
|
|
219
252
|
return 500;
|
|
220
253
|
}
|
|
221
254
|
}
|
|
222
255
|
|
|
256
|
+
/** A refused or failed user-surface change: the workflow's status, or a 500. */
|
|
257
|
+
function sendUserSurfaceFailure(request, reply, err, fallback) {
|
|
258
|
+
if (err instanceof TunnelError) {
|
|
259
|
+
const status = tunnelErrorStatus(err.code);
|
|
260
|
+
return reply.code(status).send({
|
|
261
|
+
error: status === 500 ? fallback : err.message,
|
|
262
|
+
...(status === 500 ? { details: err.message } : {}),
|
|
263
|
+
...(err.code === 'USER_SURFACE_CONFLICT' ? { code: 'user_surface_conflict' } : {}),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (Number.isInteger(err.statusCode)) {
|
|
267
|
+
return reply.code(err.statusCode).send({ error: err.message });
|
|
268
|
+
}
|
|
269
|
+
request.log.error(err, fallback);
|
|
270
|
+
return reply.code(500).send({ error: fallback, details: err.message });
|
|
271
|
+
}
|
|
272
|
+
|
|
223
273
|
export default async function tunnelRoutes(fastify, _opts) {
|
|
224
274
|
const nginxDeps = tunnelNginxDeps;
|
|
225
275
|
const certbotDeps = tunnelCertbotDeps;
|
|
@@ -348,7 +398,9 @@ export default async function tunnelRoutes(fastify, _opts) {
|
|
|
348
398
|
});
|
|
349
399
|
|
|
350
400
|
const total = tunnels.length;
|
|
351
|
-
const windowed =
|
|
401
|
+
const windowed = await Promise.all(
|
|
402
|
+
tunnels.slice(offset, offset + limit).map((t) => withUserSurfaceStatus(t)),
|
|
403
|
+
);
|
|
352
404
|
|
|
353
405
|
return { tunnels: windowed, total, limit, offset };
|
|
354
406
|
},
|
|
@@ -547,6 +599,7 @@ export default async function tunnelRoutes(fastify, _opts) {
|
|
|
547
599
|
chisel: chiselDeps,
|
|
548
600
|
state: stateDeps,
|
|
549
601
|
agents: agentDeps,
|
|
602
|
+
userSurface: userSurfaceDeps,
|
|
550
603
|
logger: request.log,
|
|
551
604
|
authorize: authorizeFor(request, 'change'),
|
|
552
605
|
});
|
|
@@ -577,7 +630,7 @@ export default async function tunnelRoutes(fastify, _opts) {
|
|
|
577
630
|
const { id } = IdParamSchema.parse(request.params);
|
|
578
631
|
|
|
579
632
|
try {
|
|
580
|
-
await deleteTunnel({
|
|
633
|
+
const { tunnel } = await deleteTunnel({
|
|
581
634
|
id,
|
|
582
635
|
nginx: nginxDeps,
|
|
583
636
|
chisel: chiselDeps,
|
|
@@ -585,6 +638,11 @@ export default async function tunnelRoutes(fastify, _opts) {
|
|
|
585
638
|
logger: request.log,
|
|
586
639
|
authorize: authorizeFor(request, 'delete'),
|
|
587
640
|
});
|
|
641
|
+
// Its user-surface bindings went with it.
|
|
642
|
+
await auditDeletedTunnelUserSurface(tunnel, {
|
|
643
|
+
actor: actorFromRequest(request),
|
|
644
|
+
logger: request.log,
|
|
645
|
+
});
|
|
588
646
|
return { ok: true };
|
|
589
647
|
} catch (err) {
|
|
590
648
|
if (err instanceof TunnelError) {
|
|
@@ -603,6 +661,44 @@ export default async function tunnelRoutes(fastify, _opts) {
|
|
|
603
661
|
},
|
|
604
662
|
);
|
|
605
663
|
|
|
664
|
+
// PUT /api/tunnels/:id/user-surface — serve a plugin's user surface on this
|
|
665
|
+
// tunnel's host (from when its installed, approved version declares identity)
|
|
666
|
+
fastify.put(
|
|
667
|
+
'/tunnels/:id/user-surface',
|
|
668
|
+
{ preHandler: fastify.requireRole(['admin']), config: USER_SURFACE_WRITE_RATE_LIMIT },
|
|
669
|
+
async (request, reply) => {
|
|
670
|
+
const { id } = IdParamSchema.parse(request.params);
|
|
671
|
+
const { route } = UserSurfaceBodySchema.parse(request.body);
|
|
672
|
+
try {
|
|
673
|
+
const { changed, tunnel } = await bindTunnelUserSurface(id, route, {
|
|
674
|
+
actor: actorFromRequest(request),
|
|
675
|
+
logger: request.log,
|
|
676
|
+
});
|
|
677
|
+
return { ok: true, changed, tunnel };
|
|
678
|
+
} catch (err) {
|
|
679
|
+
return sendUserSurfaceFailure(request, reply, err, 'Failed to bind the user surface');
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
// DELETE /api/tunnels/:id/user-surface/:route — stop serving it there
|
|
685
|
+
fastify.delete(
|
|
686
|
+
'/tunnels/:id/user-surface/:route',
|
|
687
|
+
{ preHandler: fastify.requireRole(['admin']), config: USER_SURFACE_WRITE_RATE_LIMIT },
|
|
688
|
+
async (request, reply) => {
|
|
689
|
+
const { id, route } = UserSurfaceParamsSchema.parse(request.params);
|
|
690
|
+
try {
|
|
691
|
+
const { tunnel } = await unbindTunnelUserSurface(id, route, {
|
|
692
|
+
actor: actorFromRequest(request),
|
|
693
|
+
logger: request.log,
|
|
694
|
+
});
|
|
695
|
+
return { ok: true, tunnel };
|
|
696
|
+
} catch (err) {
|
|
697
|
+
return sendUserSurfaceFailure(request, reply, err, 'Failed to unbind the user surface');
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
);
|
|
701
|
+
|
|
606
702
|
// GET /api/tunnels/agent-panel-status — check if agent has a panel tunnel
|
|
607
703
|
fastify.get(
|
|
608
704
|
'/tunnels/agent-panel-status',
|