@lamalibre/create-lamaste 4.6.0 → 4.7.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/bundle-vendor.js +20 -3
- package/scripts/lamaste-priv +22 -3
- package/scripts/pin-release.js +98 -0
- package/src/index.js +8 -1
- package/src/lib/env.js +19 -0
- package/src/lib/service-config.js +7 -0
- package/src/tasks/redeploy.js +8 -0
- 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 +6 -2
- package/vendor/docs/01-concepts/authorization.md +4 -0
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/dns-and-domains.md +9 -0
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +28 -3
- 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 +5 -11
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/second-domain.md +82 -0
- 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 +1 -1
- package/vendor/docs/03-architecture/nginx-configuration.md +15 -5
- package/vendor/docs/03-architecture/overview.md +5 -5
- package/vendor/docs/03-architecture/panel-client.md +6 -6
- package/vendor/docs/03-architecture/panel-server.md +2 -2
- package/vendor/docs/03-architecture/state-management.md +3 -3
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +17 -13
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/domains.md +192 -0
- package/vendor/docs/04-api-reference/gatekeeper.md +35 -7
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +4 -4
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +9 -0
- package/vendor/docs/04-api-reference/registry-and-oidc.md +26 -13
- package/vendor/docs/04-api-reference/sites.md +5 -0
- package/vendor/docs/04-api-reference/tunnels.md +15 -10
- package/vendor/docs/04-api-reference/users.md +41 -29
- package/vendor/docs/05-operations/backup-and-restore.md +1 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +6 -6
- package/vendor/docs/06-reference/config-files.md +1 -1
- 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 +1 -1
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/_index.json +105 -19
- package/vendor/docs/external-members-design.md +816 -0
- package/vendor/docs/gatekeeper-design.md +8 -8
- package/vendor/docs/identity-directory-design.md +3 -3
- package/vendor/docs/multi-domain-relay-design.md +836 -0
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/cli/index.js +0 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/grants.js +20 -13
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0004_unconsume_access_grants.sql +8 -0
- package/vendor/gatekeeper/dist/lib/secret.d.ts +6 -0
- package/vendor/gatekeeper/dist/lib/secret.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/secret.js +12 -0
- package/vendor/gatekeeper/dist/lib/secret.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts +20 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/templates.js +80 -14
- package/vendor/gatekeeper/dist/lib/templates.js.map +1 -1
- package/vendor/gatekeeper/dist/server/index.d.ts +5 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +28 -2
- package/vendor/gatekeeper/dist/server/index.js.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/routes/authz.js +225 -36
- 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 +5 -2
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -1
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +7 -0
- package/vendor/server/package.json +2 -2
- package/vendor/server/src/commands/domains.js +214 -0
- package/vendor/server/src/commands/plugin-identity.js +2 -16
- package/vendor/server/src/commands/plugins.js +112 -8
- package/vendor/server/src/commands/registry-credential.js +10 -3
- package/vendor/server/src/commands/sites.js +3 -0
- package/vendor/server/src/commands/status.js +19 -0
- package/vendor/server/src/commands/tunnels.js +4 -1
- package/vendor/server/src/commands/users.js +8 -19
- package/vendor/server/src/domains.js +92 -0
- package/vendor/server/src/fail.js +21 -0
- package/vendor/server/src/oidc-client-grants.js +63 -0
- package/vendor/server/src/panel-api.js +15 -0
- package/vendor/server-ui/dist/assets/index-B8Tv_ocF.js +84 -0
- package/vendor/server-ui/dist/assets/{index-BZwQ_aQc.css → index-Cb-ADsbB.css} +1 -1
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +42 -16
- package/vendor/serverd/src/lib/app-error.js +24 -0
- package/vendor/serverd/src/lib/audience.js +12 -3
- package/vendor/serverd/src/lib/authelia.js +341 -35
- package/vendor/serverd/src/lib/directory.js +67 -52
- package/vendor/serverd/src/lib/domains.js +1792 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +20 -4
- package/vendor/serverd/src/lib/identity-sync.js +35 -0
- package/vendor/serverd/src/lib/migrations/state/0016_domains.sql +53 -0
- package/vendor/serverd/src/lib/migrations/state/0017_membership_kind.sql +15 -0
- package/vendor/serverd/src/lib/migrations/state/0018_domain_group_keys.sql +23 -0
- package/vendor/serverd/src/lib/nginx.js +318 -140
- package/vendor/serverd/src/lib/owned-groups.js +326 -0
- package/vendor/serverd/src/lib/plugin-domain.js +86 -0
- package/vendor/serverd/src/lib/plugin-lifecycle.js +1 -0
- package/vendor/serverd/src/lib/plugin-oidc.js +144 -42
- package/vendor/serverd/src/lib/plugins.js +45 -5
- package/vendor/serverd/src/lib/registry-credential.js +39 -6
- package/vendor/serverd/src/lib/services.js +9 -1
- package/vendor/serverd/src/lib/state.js +11 -0
- package/vendor/serverd/src/lib/tunnel-deps.js +2 -4
- package/vendor/serverd/src/lib/user-surface.js +29 -4
- package/vendor/serverd/src/lib/users-lock.js +23 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +149 -41
- package/vendor/serverd/src/lib/web-surface.js +49 -32
- package/vendor/serverd/src/middleware/user-access-session.js +11 -4
- package/vendor/serverd/src/routes/invite.js +15 -5
- package/vendor/serverd/src/routes/management/domains.js +154 -0
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +44 -8
- package/vendor/serverd/src/routes/management/identity.js +21 -7
- package/vendor/serverd/src/routes/management/invitations.js +33 -4
- package/vendor/serverd/src/routes/management/plugins.js +4 -2
- package/vendor/serverd/src/routes/management/registry-credential.js +86 -57
- package/vendor/serverd/src/routes/management/sites.js +50 -11
- package/vendor/serverd/src/routes/management/tunnels.js +29 -5
- package/vendor/serverd/src/routes/management/users.js +184 -64
- package/vendor/serverd/src/routes/management.js +2 -0
- package/vendor/serverd/src/routes/onboarding/dns.js +3 -17
- package/vendor/serverd/src/routes/onboarding/provision.js +22 -13
- package/vendor/serverd/src/routes/user-access.js +13 -1
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +0 -7
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +0 -14
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +0 -9
- package/vendor/server-ui/dist/assets/index-BvEfCZYS.js +0 -84
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay's base domains and their members (the multi-domain relay,
|
|
3
|
+
* lib/domains.js). Admin only (mTLS).
|
|
4
|
+
*
|
|
5
|
+
* GET /api/domains domains, member counts (members, external), what uses each
|
|
6
|
+
* POST /api/domains add one (`pending`)
|
|
7
|
+
* POST /api/domains/:name/verify-dns does auth.<domain> resolve here?
|
|
8
|
+
* POST /api/domains/:name/activate issue auth.<domain>, serve its sign-in
|
|
9
|
+
* DELETE /api/domains/:name remove an unused, non-primary domain
|
|
10
|
+
* GET /api/domains/:name/members its members, each with the membership's kind
|
|
11
|
+
* PUT /api/domains/:name/members/:user make a user (id or username) a member —
|
|
12
|
+
* `{ kind: 'member' | 'external' }`, member
|
|
13
|
+
* unless named; another kind changes it
|
|
14
|
+
* DELETE /api/domains/:name/members/:user end a membership
|
|
15
|
+
* PUT /api/plugins/:name/domain move a plugin to another domain
|
|
16
|
+
*
|
|
17
|
+
* Routes validate and answer; the work is in lib/domains.js and
|
|
18
|
+
* lib/plugin-domain.js. Refusals answer `{ error, code }`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
import { DomainNameSchema, MEMBERSHIP_KINDS } from '@lamalibre/lamaste';
|
|
23
|
+
import { actorFromRequest } from '../../lib/actor.js';
|
|
24
|
+
import { sendFailure } from '../../lib/app-error.js';
|
|
25
|
+
import {
|
|
26
|
+
activateDomain,
|
|
27
|
+
addDomain,
|
|
28
|
+
addDomainMember,
|
|
29
|
+
domainsOverview,
|
|
30
|
+
getDomain,
|
|
31
|
+
listDomainMembers,
|
|
32
|
+
newUsersNeedDomains,
|
|
33
|
+
removeDomain,
|
|
34
|
+
removeDomainMember,
|
|
35
|
+
verifyDomainDns,
|
|
36
|
+
} from '../../lib/domains.js';
|
|
37
|
+
import { setPluginDomain } from '../../lib/plugin-domain.js';
|
|
38
|
+
|
|
39
|
+
const NameParamSchema = z.object({ name: DomainNameSchema });
|
|
40
|
+
// `user`: a directory id or a username (lib/domains.js decides which).
|
|
41
|
+
const MemberParamsSchema = z.object({ name: DomainNameSchema, user: z.string().min(1).max(64) });
|
|
42
|
+
const AddDomainSchema = z.object({ name: DomainNameSchema }).strict();
|
|
43
|
+
const MemberBodySchema = z.object({ kind: z.enum(MEMBERSHIP_KINDS).optional() }).strict();
|
|
44
|
+
const PluginParamSchema = z.object({ name: z.string().min(1).max(200) });
|
|
45
|
+
const PluginDomainSchema = z.object({ domain: DomainNameSchema }).strict();
|
|
46
|
+
|
|
47
|
+
export default async function domainRoutes(fastify) {
|
|
48
|
+
const admin = { preHandler: fastify.requireRole(['admin']) };
|
|
49
|
+
|
|
50
|
+
fastify.get('/domains', admin, async () => {
|
|
51
|
+
const domains = await domainsOverview();
|
|
52
|
+
// Whether users and invitations must name their domains (`domains_required`).
|
|
53
|
+
return { domains, newUsersNeedDomains: newUsersNeedDomains(domains) };
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
fastify.post('/domains', admin, async (request, reply) => {
|
|
57
|
+
const { name } = AddDomainSchema.parse(request.body);
|
|
58
|
+
try {
|
|
59
|
+
const domain = await addDomain(name, {
|
|
60
|
+
actor: actorFromRequest(request),
|
|
61
|
+
logger: request.log,
|
|
62
|
+
});
|
|
63
|
+
return reply.code(201).send({ domain });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return sendFailure(request, reply, err, 'The domain change failed');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
fastify.post(
|
|
70
|
+
'/domains/:name/verify-dns',
|
|
71
|
+
{ ...admin, config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
|
|
72
|
+
async (request, reply) => {
|
|
73
|
+
const { name } = NameParamSchema.parse(request.params);
|
|
74
|
+
try {
|
|
75
|
+
return await verifyDomainDns(name);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return sendFailure(request, reply, err, 'The domain change failed');
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
fastify.post(
|
|
83
|
+
'/domains/:name/activate',
|
|
84
|
+
{ ...admin, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
|
85
|
+
async (request, reply) => {
|
|
86
|
+
const { name } = NameParamSchema.parse(request.params);
|
|
87
|
+
try {
|
|
88
|
+
const domain = await activateDomain(name, {
|
|
89
|
+
actor: actorFromRequest(request),
|
|
90
|
+
logger: request.log,
|
|
91
|
+
});
|
|
92
|
+
return { domain };
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return sendFailure(request, reply, err, 'The domain change failed');
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
fastify.delete('/domains/:name', admin, async (request, reply) => {
|
|
100
|
+
const { name } = NameParamSchema.parse(request.params);
|
|
101
|
+
try {
|
|
102
|
+
return await removeDomain(name, { actor: actorFromRequest(request), logger: request.log });
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return sendFailure(request, reply, err, 'The domain change failed');
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
fastify.get('/domains/:name/members', admin, async (request, reply) => {
|
|
109
|
+
const { name } = NameParamSchema.parse(request.params);
|
|
110
|
+
if (!(await getDomain(name))) {
|
|
111
|
+
return reply.code(404).send({ error: 'Domain not found', code: 'domain_not_found' });
|
|
112
|
+
}
|
|
113
|
+
return { members: await listDomainMembers(name) };
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
fastify.put('/domains/:name/members/:user', admin, async (request, reply) => {
|
|
117
|
+
const { name, user } = MemberParamsSchema.parse(request.params);
|
|
118
|
+
const { kind } = MemberBodySchema.parse(request.body ?? {});
|
|
119
|
+
try {
|
|
120
|
+
return await addDomainMember(name, user, {
|
|
121
|
+
...(kind ? { kind } : {}),
|
|
122
|
+
actor: actorFromRequest(request),
|
|
123
|
+
logger: request.log,
|
|
124
|
+
});
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return sendFailure(request, reply, err, 'The domain change failed');
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
fastify.delete('/domains/:name/members/:user', admin, async (request, reply) => {
|
|
131
|
+
const { name, user } = MemberParamsSchema.parse(request.params);
|
|
132
|
+
try {
|
|
133
|
+
return await removeDomainMember(name, user, {
|
|
134
|
+
actor: actorFromRequest(request),
|
|
135
|
+
logger: request.log,
|
|
136
|
+
});
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return sendFailure(request, reply, err, 'The domain change failed');
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
fastify.put('/plugins/:name/domain', admin, async (request, reply) => {
|
|
143
|
+
const { name } = PluginParamSchema.parse(request.params);
|
|
144
|
+
const { domain } = PluginDomainSchema.parse(request.body);
|
|
145
|
+
try {
|
|
146
|
+
return await setPluginDomain(name, domain, {
|
|
147
|
+
actor: actorFromRequest(request),
|
|
148
|
+
logger: request.log,
|
|
149
|
+
});
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return sendFailure(request, reply, err, 'The domain change failed');
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -21,13 +21,24 @@
|
|
|
21
21
|
* cannot be) — a reused username inherits nothing. The access log is purged
|
|
22
22
|
* for one username by POST, its name in the body (never in a URL, which
|
|
23
23
|
* logs keep).
|
|
24
|
+
*
|
|
25
|
+
* Grants and groups also decide what external members of a domain reach
|
|
26
|
+
* (packages/core/docs/external-members-design.md): after every change that
|
|
27
|
+
* went through, the domains are rendered (users.yml's owned groups — the
|
|
28
|
+
* restricted hosts and OIDC clients that name each external member), which
|
|
29
|
+
* follows whoever it narrowed (registry tokens, a device-code session). A
|
|
30
|
+
* rendering that fails is answered with `warnings` and tried again within
|
|
31
|
+
* seconds. A grant on an OIDC client (`resourceType: 'oidc-client'`) must
|
|
32
|
+
* name a user-facing client an installed plugin declares.
|
|
24
33
|
*/
|
|
25
34
|
|
|
26
35
|
import { recomputeAudiences } from '../../lib/audience.js';
|
|
27
36
|
import { withUsernamesForGrants } from '../../lib/directory.js';
|
|
37
|
+
import { scheduleRenderRetry, syncDomainAccess } from '../../lib/domains.js';
|
|
28
38
|
import { checkProxiedGrant } from '../../lib/entitlements.js';
|
|
29
39
|
import { actorFromRequest } from '../../lib/actor.js';
|
|
30
40
|
import { gatekeeperRequest } from '../../lib/gatekeeper-client.js';
|
|
41
|
+
import { checkOidcClientGrant } from '../../lib/owned-groups.js';
|
|
31
42
|
|
|
32
43
|
/** Gatekeeper's `path`, with the request's query string when it has one. */
|
|
33
44
|
function withQuery(path, request) {
|
|
@@ -55,18 +66,43 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
55
66
|
};
|
|
56
67
|
};
|
|
57
68
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
/**
|
|
70
|
+
* When Gatekeeper took a grant or group change: the plugin audiences and
|
|
71
|
+
* the domains' rendering follow it — which follows whoever it narrowed —
|
|
72
|
+
* all before the answer goes back. When the rendering fails, the answer
|
|
73
|
+
* carries `warnings` (Gatekeeper took the change, and enforces it on its
|
|
74
|
+
* own hosts; an OIDC client it no longer grants waits for a rendering)
|
|
75
|
+
* and one is tried again within seconds.
|
|
76
|
+
*/
|
|
77
|
+
const followChange = async (request, answer) => {
|
|
78
|
+
if (answer.statusCode >= 400) return answer;
|
|
79
|
+
await recomputeAudiences({ logger: request.log });
|
|
80
|
+
let warnings;
|
|
81
|
+
try {
|
|
82
|
+
warnings = (await syncDomainAccess({ logger: request.log })).warnings ?? [];
|
|
83
|
+
} catch (err) {
|
|
84
|
+
request.log.warn({ err: err.message }, 'Rendering domain access after a grant change failed');
|
|
85
|
+
scheduleRenderRetry(request.log);
|
|
86
|
+
warnings = [
|
|
87
|
+
...(err.warnings ?? []),
|
|
88
|
+
{
|
|
89
|
+
step: 'render',
|
|
90
|
+
message: `Gatekeeper took the change, but who may reach what was not fully rendered (${err.message}); the panel tries again within seconds`,
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
if (warnings.length === 0 || typeof answer.data !== 'object' || answer.data === null) {
|
|
95
|
+
return answer;
|
|
96
|
+
}
|
|
97
|
+
return { ...answer, data: { ...answer.data, warnings } };
|
|
62
98
|
};
|
|
63
99
|
|
|
64
100
|
/**
|
|
65
101
|
* A grant or group change: forwarded, and when Gatekeeper took it, the
|
|
66
|
-
* plugin audiences follow it before the answer goes back.
|
|
102
|
+
* plugin audiences and the domains follow it before the answer goes back.
|
|
67
103
|
*/
|
|
68
104
|
const proxyChange = async (request, method, gatekeeperPath, body) =>
|
|
69
|
-
|
|
105
|
+
followChange(request, await proxy(method, gatekeeperPath, body));
|
|
70
106
|
|
|
71
107
|
/**
|
|
72
108
|
* Give something to `usernames` in Gatekeeper: forwarded holding the
|
|
@@ -85,7 +121,7 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
85
121
|
if (!Number.isInteger(err.statusCode)) throw err;
|
|
86
122
|
return { statusCode: err.statusCode, data: { error: err.message, code: err.code } };
|
|
87
123
|
}
|
|
88
|
-
return
|
|
124
|
+
return followChange(request, answer);
|
|
89
125
|
};
|
|
90
126
|
|
|
91
127
|
// --- Groups ---
|
|
@@ -164,7 +200,7 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
164
200
|
fastify.post('/gatekeeper/grants', async (request, reply) => {
|
|
165
201
|
let body;
|
|
166
202
|
try {
|
|
167
|
-
body = await checkProxiedGrant(request.body);
|
|
203
|
+
body = await checkOidcClientGrant(await checkProxiedGrant(request.body));
|
|
168
204
|
} catch (err) {
|
|
169
205
|
if (!Number.isInteger(err.statusCode)) throw err;
|
|
170
206
|
return reply.code(err.statusCode).send({
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { readUsers } from '../../lib/authelia.js';
|
|
3
|
+
import { adminGroups, domainNames } from '../../lib/domains.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Authelia's users with their groups as anything outside Authelia sees
|
|
7
|
+
* them: the groups Lamaste owns (domain memberships, host groups, OIDC
|
|
8
|
+
* client grants) left out.
|
|
9
|
+
*/
|
|
10
|
+
async function shownUsers() {
|
|
11
|
+
const [users, domains] = await Promise.all([readUsers(), domainNames()]);
|
|
12
|
+
return users.map((u) => ({ ...u, groups: adminGroups(u.groups, domains) }));
|
|
13
|
+
}
|
|
3
14
|
|
|
4
15
|
const UsernameParamSchema = z.object({
|
|
5
16
|
username: z
|
|
@@ -37,10 +48,13 @@ export default async function identityRoutes(fastify, _opts) {
|
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
const groupsRaw = headerValue(request.headers['remote-groups']) || '';
|
|
40
|
-
const groups =
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
const groups = adminGroups(
|
|
52
|
+
groupsRaw
|
|
53
|
+
.split(',')
|
|
54
|
+
.map((g) => g.trim())
|
|
55
|
+
.filter(Boolean),
|
|
56
|
+
await domainNames(),
|
|
57
|
+
);
|
|
44
58
|
const name = headerValue(request.headers['remote-name']) || '';
|
|
45
59
|
const email = headerValue(request.headers['remote-email']) || '';
|
|
46
60
|
|
|
@@ -54,7 +68,7 @@ export default async function identityRoutes(fastify, _opts) {
|
|
|
54
68
|
{ preHandler: fastify.requireRole(['admin', 'agent'], { capability: 'identity:query' }) },
|
|
55
69
|
async (request, reply) => {
|
|
56
70
|
try {
|
|
57
|
-
const users = await
|
|
71
|
+
const users = await shownUsers();
|
|
58
72
|
return { users };
|
|
59
73
|
} catch (err) {
|
|
60
74
|
request.log.error({ err }, 'Failed to read Authelia users');
|
|
@@ -70,7 +84,7 @@ export default async function identityRoutes(fastify, _opts) {
|
|
|
70
84
|
async (request, reply) => {
|
|
71
85
|
const { username } = UsernameParamSchema.parse(request.params);
|
|
72
86
|
try {
|
|
73
|
-
const users = await
|
|
87
|
+
const users = await shownUsers();
|
|
74
88
|
const user = users.find((u) => u.username === username);
|
|
75
89
|
if (!user) {
|
|
76
90
|
return reply.code(404).send({ error: 'User not found' });
|
|
@@ -89,7 +103,7 @@ export default async function identityRoutes(fastify, _opts) {
|
|
|
89
103
|
{ preHandler: fastify.requireRole(['admin', 'agent'], { capability: 'identity:query' }) },
|
|
90
104
|
async (request, reply) => {
|
|
91
105
|
try {
|
|
92
|
-
const users = await
|
|
106
|
+
const users = await shownUsers();
|
|
93
107
|
/** @type {Set<string>} */
|
|
94
108
|
const groupSet = new Set();
|
|
95
109
|
for (const u of users) {
|
|
@@ -3,7 +3,15 @@ import { z } from 'zod';
|
|
|
3
3
|
import { USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_REGEX } from '@lamalibre/lamaste';
|
|
4
4
|
import { readInvitations, withInvitationsLock, writeInvitations } from '../../lib/state.js';
|
|
5
5
|
import { readUsersRaw } from '../../lib/authelia.js';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
MembershipInputSchema,
|
|
8
|
+
assertAssignableGroups,
|
|
9
|
+
domainsForNewUser,
|
|
10
|
+
externalMemberGroupsError,
|
|
11
|
+
invitationMemberships,
|
|
12
|
+
invitationSignInDomain,
|
|
13
|
+
} from '../../lib/domains.js';
|
|
14
|
+
import { sendFailure } from '../../lib/app-error.js';
|
|
7
15
|
|
|
8
16
|
const IdParamSchema = z.object({ id: z.string().uuid() });
|
|
9
17
|
|
|
@@ -19,8 +27,15 @@ const CreateInvitationSchema = z.object({
|
|
|
19
27
|
'Username must start with a lowercase letter or digit and may contain underscores or hyphens',
|
|
20
28
|
),
|
|
21
29
|
email: z.string().email(),
|
|
22
|
-
groups
|
|
30
|
+
// Authelia groups (`admins`, `feria-*`): letters, digits, "-" and "_" —
|
|
31
|
+
// never for an external member.
|
|
32
|
+
groups: z.array(z.string()).max(100).optional().default([]),
|
|
23
33
|
expiresInDays: z.number().int().min(1).max(30).optional().default(7),
|
|
34
|
+
// The domains the user becomes a member of on accepting — a name (a
|
|
35
|
+
// member) or `{ name, kind }` (an external member reaches only what names
|
|
36
|
+
// them). Required while the relay has two or more; with one, the user
|
|
37
|
+
// becomes its member.
|
|
38
|
+
domains: z.array(MembershipInputSchema).max(50).optional(),
|
|
24
39
|
});
|
|
25
40
|
|
|
26
41
|
export default async function invitationRoutes(fastify, _opts) {
|
|
@@ -37,6 +52,7 @@ export default async function invitationRoutes(fastify, _opts) {
|
|
|
37
52
|
const enriched = invitations.map((inv) => ({
|
|
38
53
|
...inv,
|
|
39
54
|
token: undefined,
|
|
55
|
+
domains: invitationMemberships(inv),
|
|
40
56
|
status: inv.used ? 'accepted' : new Date(inv.expiresAt) < now ? 'expired' : 'pending',
|
|
41
57
|
}));
|
|
42
58
|
return { invitations: enriched };
|
|
@@ -55,6 +71,16 @@ export default async function invitationRoutes(fastify, _opts) {
|
|
|
55
71
|
},
|
|
56
72
|
async (request, reply) => {
|
|
57
73
|
const body = CreateInvitationSchema.parse(request.body);
|
|
74
|
+
let domains;
|
|
75
|
+
try {
|
|
76
|
+
assertAssignableGroups(body.groups);
|
|
77
|
+
domains = await domainsForNewUser(body.domains, { required: true });
|
|
78
|
+
if (body.groups.length > 0 && domains.some((m) => m.kind === 'external')) {
|
|
79
|
+
throw externalMemberGroupsError(400);
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return sendFailure(request, reply, err, 'Failed to read the domains');
|
|
83
|
+
}
|
|
58
84
|
|
|
59
85
|
// Check username doesn't already exist in Authelia
|
|
60
86
|
let usersData;
|
|
@@ -97,6 +123,7 @@ export default async function invitationRoutes(fastify, _opts) {
|
|
|
97
123
|
username: body.username,
|
|
98
124
|
email: body.email,
|
|
99
125
|
groups: body.groups,
|
|
126
|
+
domains,
|
|
100
127
|
createdAt: now.toISOString(),
|
|
101
128
|
expiresAt: expiresAt.toISOString(),
|
|
102
129
|
used: false,
|
|
@@ -116,8 +143,10 @@ export default async function invitationRoutes(fastify, _opts) {
|
|
|
116
143
|
|
|
117
144
|
const { invitation } = outcome;
|
|
118
145
|
const { token } = invitation;
|
|
119
|
-
|
|
120
|
-
|
|
146
|
+
// On the sign-in page of the first domain it names (the invitation
|
|
147
|
+
// page is served on every domain's auth portal).
|
|
148
|
+
const inviteDomain = await invitationSignInDomain(invitation);
|
|
149
|
+
const inviteUrl = inviteDomain ? `https://auth.${inviteDomain}/invite/${token}` : null;
|
|
121
150
|
|
|
122
151
|
return reply.code(201).send({
|
|
123
152
|
ok: true,
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
} from '../../lib/plugin-settings.js';
|
|
43
43
|
import { actorFromRequest, certSerialFromRequest } from '../../lib/actor.js';
|
|
44
44
|
import { recordAudit } from '../../lib/audit.js';
|
|
45
|
+
import { withPluginDomains } from '../../lib/domains.js';
|
|
45
46
|
import {
|
|
46
47
|
readPushInstallConfig,
|
|
47
48
|
readPushInstallSessions,
|
|
@@ -337,7 +338,7 @@ export default async function pluginRoutes(fastify, _opts) {
|
|
|
337
338
|
// (see GET /plugins/:name/settings).
|
|
338
339
|
const plugins = await attachSettingsSummaries(await attachHostings(registry.plugins));
|
|
339
340
|
return {
|
|
340
|
-
plugins: await Promise.all(plugins.map(withHooks)),
|
|
341
|
+
plugins: await withPluginDomains(await Promise.all(plugins.map(withHooks))),
|
|
341
342
|
// Every agent's retained data — of uninstalled plugins too, which
|
|
342
343
|
// no entry of `plugins` lists any more.
|
|
343
344
|
retainedData: await listRetainedData(),
|
|
@@ -363,7 +364,8 @@ export default async function pluginRoutes(fastify, _opts) {
|
|
|
363
364
|
}
|
|
364
365
|
|
|
365
366
|
const [withHosting] = await attachSettingsSummaries(await attachHostings([plugin]));
|
|
366
|
-
|
|
367
|
+
const [shown] = await withPluginDomains([await withHooks(withHosting)]);
|
|
368
|
+
return { plugin: shown };
|
|
367
369
|
},
|
|
368
370
|
);
|
|
369
371
|
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* `auth.kind` is `token` (a pasted long-lived Feria token) or `oidc` (the
|
|
11
11
|
* panel's own `lamaste-server` client on Authelia: client-credentials grant,
|
|
12
|
-
* Feria login
|
|
12
|
+
* Feria login; `issuerDomain` names the active domain whose issuer,
|
|
13
|
+
* `https://auth.<domain>`, the registry trusts — the primary when absent). For `oidc` the client is written to Authelia before the
|
|
13
14
|
* chain is verified; if verification fails it is removed again, and the
|
|
14
15
|
* error says what Feria's `auth.json` needs.
|
|
15
16
|
*
|
|
@@ -28,9 +29,12 @@ import {
|
|
|
28
29
|
clearRegistryCredentialConfig,
|
|
29
30
|
readRegistryCredentialConfig,
|
|
30
31
|
verifyRegistryCredential,
|
|
31
|
-
|
|
32
|
+
setRegistryCredential,
|
|
32
33
|
} from '../../lib/registry-credential.js';
|
|
33
|
-
import {
|
|
34
|
+
import { DomainNameSchema } from '@lamalibre/lamaste';
|
|
35
|
+
import { sendFailure } from '../../lib/app-error.js';
|
|
36
|
+
import { resolveActiveDomain } from '../../lib/domains.js';
|
|
37
|
+
import { syncOidcClients, withRegistryCandidate } from '../../lib/plugin-oidc.js';
|
|
34
38
|
|
|
35
39
|
const FeriaTokenSchema = z
|
|
36
40
|
.string()
|
|
@@ -40,7 +44,7 @@ const FeriaTokenSchema = z
|
|
|
40
44
|
|
|
41
45
|
const AuthSchema = z.discriminatedUnion('kind', [
|
|
42
46
|
z.object({ kind: z.literal('token'), token: FeriaTokenSchema }),
|
|
43
|
-
z.object({ kind: z.literal('oidc') }),
|
|
47
|
+
z.object({ kind: z.literal('oidc'), issuerDomain: DomainNameSchema.optional() }),
|
|
44
48
|
]);
|
|
45
49
|
|
|
46
50
|
const PutSchema = z
|
|
@@ -64,9 +68,13 @@ const PutSchema = z
|
|
|
64
68
|
}
|
|
65
69
|
});
|
|
66
70
|
|
|
67
|
-
/** The stored `auth` for a request body. */
|
|
68
|
-
function authOf(body) {
|
|
69
|
-
if (body.auth?.kind === 'oidc')
|
|
71
|
+
/** The stored `auth` for a request body (`issuerDomain` resolved against the active domains). */
|
|
72
|
+
async function authOf(body) {
|
|
73
|
+
if (body.auth?.kind === 'oidc') {
|
|
74
|
+
const auth = { kind: 'oidc', clientId: LAMASTE_SERVER_OIDC_CLIENT_ID };
|
|
75
|
+
if (body.auth.issuerDomain === undefined) return auth;
|
|
76
|
+
return { ...auth, issuerDomain: await resolveActiveDomain(body.auth.issuerDomain) };
|
|
77
|
+
}
|
|
70
78
|
if (body.auth?.kind === 'token') return { kind: 'token', token: body.auth.token };
|
|
71
79
|
return { kind: 'token', token: body.token };
|
|
72
80
|
}
|
|
@@ -90,62 +98,83 @@ export default async function registryCredentialRoutes(fastify) {
|
|
|
90
98
|
},
|
|
91
99
|
async (request, reply) => {
|
|
92
100
|
const body = PutSchema.parse(request.body);
|
|
93
|
-
|
|
101
|
+
let auth;
|
|
102
|
+
try {
|
|
103
|
+
auth = await authOf(body);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return sendFailure(request, reply, err, 'Failed to read the domains');
|
|
106
|
+
}
|
|
94
107
|
const candidate = { registryUrl: body.registryUrl, auth };
|
|
95
108
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
// An oidc candidate is what every sync serves until it is stored or
|
|
110
|
+
// dropped — the identity tick's too, which would otherwise remove the
|
|
111
|
+
// panel's client from Authelia under the verification.
|
|
112
|
+
const registryAuth =
|
|
113
|
+
auth.kind === 'oidc'
|
|
114
|
+
? {
|
|
115
|
+
registryUrl: body.registryUrl,
|
|
116
|
+
...(auth.issuerDomain ? { issuerDomain: auth.issuerDomain } : {}),
|
|
117
|
+
}
|
|
118
|
+
: undefined;
|
|
119
|
+
try {
|
|
120
|
+
return await withRegistryCandidate(registryAuth, async () => {
|
|
121
|
+
// The panel's own client must exist in Authelia (with a secret in
|
|
122
|
+
// the store) before the chain can be exercised.
|
|
123
|
+
if (registryAuth) {
|
|
124
|
+
try {
|
|
125
|
+
if (!(await syncOidcClients(request.log))) {
|
|
126
|
+
return reply.code(503).send({ error: 'Complete onboarding (set a domain) first' });
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return reply.code(500).send({
|
|
130
|
+
error: `Could not register the ${LAMASTE_SERVER_OIDC_CLIENT_ID} client in Authelia: ${err.message}`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
105
133
|
}
|
|
106
|
-
} catch (err) {
|
|
107
|
-
return reply.code(500).send({
|
|
108
|
-
error: `Could not register the ${LAMASTE_SERVER_OIDC_CLIENT_ID} client in Authelia: ${err.message}`,
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
134
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
let verified;
|
|
136
|
+
try {
|
|
137
|
+
verified = await verifyRegistryCredential(candidate);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const status = err instanceof RegistryCredentialError ? err.statusCode : 502;
|
|
140
|
+
return reply.code(status).send({ error: err.message });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const config = {
|
|
144
|
+
provider: 'feria',
|
|
145
|
+
registryUrl: body.registryUrl,
|
|
146
|
+
scope: body.scope ?? '@lamalibre',
|
|
147
|
+
auth,
|
|
148
|
+
owner: verified.owner,
|
|
149
|
+
configuredAt: new Date().toISOString(),
|
|
150
|
+
};
|
|
151
|
+
let warnings;
|
|
152
|
+
try {
|
|
153
|
+
warnings = await setRegistryCredential(config, { logger: request.log });
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return sendFailure(request, reply, err, 'Failed to store the registry credential');
|
|
156
|
+
}
|
|
157
|
+
request.log.info(
|
|
158
|
+
{ registryUrl: body.registryUrl, owner: verified.owner, auth: auth.kind },
|
|
159
|
+
'Registry credential configured',
|
|
122
160
|
);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
161
|
+
return {
|
|
162
|
+
configured: true,
|
|
163
|
+
...summariseRegistryCredential(config),
|
|
164
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
} finally {
|
|
168
|
+
// With the candidate dropped, the stored credential decides whether
|
|
169
|
+
// Authelia carries the panel's client: the new one once stored, the
|
|
170
|
+
// previous one after a refusal (the candidate is not kept).
|
|
171
|
+
await syncOidcClients(request.log).catch((err) =>
|
|
172
|
+
request.log.warn(
|
|
173
|
+
{ err: err.message },
|
|
174
|
+
'Failed to sync OIDC clients after the registry credential request',
|
|
175
|
+
),
|
|
176
|
+
);
|
|
126
177
|
}
|
|
127
|
-
|
|
128
|
-
const config = {
|
|
129
|
-
provider: 'feria',
|
|
130
|
-
registryUrl: body.registryUrl,
|
|
131
|
-
scope: body.scope ?? '@lamalibre',
|
|
132
|
-
auth,
|
|
133
|
-
owner: verified.owner,
|
|
134
|
-
configuredAt: new Date().toISOString(),
|
|
135
|
-
};
|
|
136
|
-
await writeRegistryCredentialConfig(config);
|
|
137
|
-
// The stored credential now decides whether Authelia carries the client.
|
|
138
|
-
await syncOidcClients(request.log).catch((err) =>
|
|
139
|
-
request.log.warn(
|
|
140
|
-
{ err: err.message },
|
|
141
|
-
'Failed to sync OIDC clients after credential change',
|
|
142
|
-
),
|
|
143
|
-
);
|
|
144
|
-
request.log.info(
|
|
145
|
-
{ registryUrl: body.registryUrl, owner: verified.owner, auth: auth.kind },
|
|
146
|
-
'Registry credential configured',
|
|
147
|
-
);
|
|
148
|
-
return { configured: true, ...summariseRegistryCredential(config) };
|
|
149
178
|
},
|
|
150
179
|
);
|
|
151
180
|
|