@lamalibre/create-lamaste 4.4.1 → 4.6.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 +234 -18
- 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/02-guides/static-sites.md +4 -0
- 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 +229 -24
- 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 +378 -34
- package/vendor/docs/04-api-reference/app-host.md +3 -3
- package/vendor/docs/04-api-reference/gatekeeper.md +78 -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/sites.md +4 -0
- package/vendor/docs/04-api-reference/tunnels.md +87 -47
- 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 +24 -6
- package/vendor/docs/06-reference/config-files.md +100 -202
- 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/cli/index.js +0 -0
- package/vendor/gatekeeper/dist/index.d.ts +4 -3
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/index.js +4 -2
- package/vendor/gatekeeper/dist/index.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/authz.d.ts +9 -0
- package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/authz.js +31 -0
- package/vendor/gatekeeper/dist/lib/authz.js.map +1 -1
- package/vendor/gatekeeper/dist/lib/constants.d.ts +5 -2
- package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/lib/constants.js +5 -2
- package/vendor/gatekeeper/dist/lib/constants.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 +10 -0
- package/vendor/gatekeeper/dist/lib/tunnels.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js +56 -0
- package/vendor/gatekeeper/dist/lib/tunnels.js.map +1 -0
- package/vendor/gatekeeper/dist/server/index.d.ts +2 -1
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
- package/vendor/gatekeeper/dist/server/index.js +49 -56
- 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 +332 -104
- 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/plugin-web-surface.js +269 -0
- package/vendor/server/src/commands/plugins.js +156 -11
- package/vendor/server/src/commands/registry-credential.js +2 -1
- package/vendor/server/src/commands/sites.js +15 -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 +186 -5
- package/vendor/server/src/plugin-identity.js +382 -0
- package/vendor/server/src/plugin-web-surface.js +307 -0
- package/vendor/server-ui/dist/assets/index-BZwQ_aQc.css +1 -0
- package/vendor/server-ui/dist/assets/index-BvEfCZYS.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 +34 -5
- package/vendor/serverd/src/lib/audience.js +576 -0
- package/vendor/serverd/src/lib/authelia.js +78 -7
- package/vendor/serverd/src/lib/chisel-runtime.js +18 -6
- package/vendor/serverd/src/lib/chisel.js +10 -2
- 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 +226 -10
- package/vendor/serverd/src/lib/hook-pipelines.js +21 -3
- 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/migrations/state/0015_plugin_web_surface.sql +14 -0
- package/vendor/serverd/src/lib/mtls.js +6 -2
- package/vendor/serverd/src/lib/nginx.js +546 -48
- package/vendor/serverd/src/lib/plugin-hooks.js +127 -45
- package/vendor/serverd/src/lib/plugin-hosting.js +33 -5
- package/vendor/serverd/src/lib/plugin-lifecycle.js +227 -13
- package/vendor/serverd/src/lib/plugin-packages.js +74 -2
- package/vendor/serverd/src/lib/plugins.js +95 -7
- 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 +36 -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 +754 -0
- package/vendor/serverd/src/lib/vhost-reconcile.js +137 -33
- package/vendor/serverd/src/lib/web-surface.js +766 -0
- 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/plugin-web-surface.js +151 -0
- package/vendor/serverd/src/routes/management/plugins.js +2 -0
- package/vendor/serverd/src/routes/management/registry-credential.js +1 -1
- package/vendor/serverd/src/routes/management/sites.js +143 -65
- package/vendor/serverd/src/routes/management/tunnels.js +109 -22
- package/vendor/serverd/src/routes/management/users.js +290 -208
- package/vendor/serverd/src/routes/management.js +6 -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,5 +1,6 @@
|
|
|
1
1
|
import fp from 'fastify-plugin';
|
|
2
2
|
import { getConfig } from '../lib/config.js';
|
|
3
|
+
import { findDirectoryUser } from '../lib/directory.js';
|
|
3
4
|
import {
|
|
4
5
|
REFRESH_THRESHOLD,
|
|
5
6
|
validateUserSession,
|
|
@@ -37,6 +38,14 @@ async function userAccessSessionPlugin(fastify, _opts) {
|
|
|
37
38
|
return reply.code(401).send({ error: 'user_session_required' });
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
// The session's user must still hold the username, and be enabled: a
|
|
42
|
+
// deleted user's username given to someone else never inherits it, and
|
|
43
|
+
// a disabled user is out at once.
|
|
44
|
+
const holder = await findDirectoryUser(result.payload.username);
|
|
45
|
+
if (!holder || holder.userId !== result.payload.userId || holder.status !== 'active') {
|
|
46
|
+
return reply.code(401).send({ error: 'user_session_required' });
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
// Set username on request for downstream route handlers
|
|
41
50
|
request.userAccessUsername = result.payload.username;
|
|
42
51
|
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* `/api/<route>/*` for agent-hosted plugins with an agent half — forwarded
|
|
3
3
|
* by the panel to the plugin's agent half over its internal tunnel, through
|
|
4
4
|
* the plugin's approved request hooks (`request:pre-forward` /
|
|
5
|
-
* `request:post-forward`)
|
|
6
|
-
*
|
|
5
|
+
* `request:post-forward`). While its hosting is down nothing is forwarded:
|
|
6
|
+
* its `tunnel:down` fallback answers, or `502` with the reason
|
|
7
|
+
* (`resolveForwardTarget`). See `createHookedForwarder` in
|
|
8
|
+
* `@lamalibre/lamaste/server`.
|
|
7
9
|
*
|
|
8
10
|
* Registered after every other `/api` route: static routes win over its
|
|
9
11
|
* `/:route/*`, so only what nothing else answers arrives here, and a route
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { readInvitations, writeInvitations } from '../lib/state.js';
|
|
3
|
+
import { readInvitations, withInvitationsLock, writeInvitations } from '../lib/state.js';
|
|
4
4
|
import {
|
|
5
5
|
createUserFromInvitation,
|
|
6
6
|
hashPassword,
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
writeTotpToDatabase,
|
|
10
10
|
} from '../lib/authelia.js';
|
|
11
11
|
import { getConfig } from '../lib/config.js';
|
|
12
|
+
import { httpError } from '../lib/app-error.js';
|
|
13
|
+
import { changeUsers, usersFileError } from '../lib/directory.js';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Find an invitation by token using a constant-time comparison.
|
|
@@ -117,15 +119,49 @@ export default async function inviteRoutes(fastify, _opts) {
|
|
|
117
119
|
return reply.code(500).send({ error: 'Failed to process password' });
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
// Through the users lock: the directory gives the user their id before
|
|
123
|
+
// Authelia restarts and lets them sign in.
|
|
120
124
|
try {
|
|
121
|
-
await
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
125
|
+
await changeUsers(
|
|
126
|
+
async () => {
|
|
127
|
+
// An unreadable users file is refused, never written over.
|
|
128
|
+
let current = { users: {} };
|
|
129
|
+
try {
|
|
130
|
+
current = await readUsersRaw();
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (err.code !== 'ENOENT') {
|
|
133
|
+
throw httpError('Failed to read user database', 500, { cause: usersFileError(err) });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (current.users[invitation.username]) {
|
|
137
|
+
throw httpError('This username already exists', 409);
|
|
138
|
+
}
|
|
139
|
+
await createUserFromInvitation(
|
|
140
|
+
invitation.username,
|
|
141
|
+
invitation.email,
|
|
142
|
+
invitation.groups,
|
|
143
|
+
hashedPassword,
|
|
144
|
+
);
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
actor: 'invitation',
|
|
148
|
+
logger: request.log,
|
|
149
|
+
// A removed user's grants, groups and second factors under the
|
|
150
|
+
// username go first; a username with an OIDC identity is refused.
|
|
151
|
+
creates: [invitation.username],
|
|
152
|
+
expect: (users) => users[invitation.username]?.password === hashedPassword,
|
|
153
|
+
},
|
|
126
154
|
);
|
|
127
155
|
} catch (err) {
|
|
128
|
-
|
|
156
|
+
if (err.statusCode === 409) {
|
|
157
|
+
return reply
|
|
158
|
+
.code(409)
|
|
159
|
+
.send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
|
|
160
|
+
}
|
|
161
|
+
if (err.statusCode === 503) {
|
|
162
|
+
return reply.code(503).send({ error: err.message, code: err.code });
|
|
163
|
+
}
|
|
164
|
+
request.log.error(err.cause ?? err, 'Failed to create user from invitation');
|
|
129
165
|
return reply.code(500).send({ error: 'Failed to create user account' });
|
|
130
166
|
}
|
|
131
167
|
|
|
@@ -140,12 +176,17 @@ export default async function inviteRoutes(fastify, _opts) {
|
|
|
140
176
|
// Non-fatal: user was created, they can have admin reset TOTP later
|
|
141
177
|
}
|
|
142
178
|
|
|
143
|
-
// Mark invitation as used
|
|
144
|
-
invitation.used = true;
|
|
145
|
-
invitation.acceptedAt = new Date().toISOString();
|
|
146
|
-
|
|
179
|
+
// Mark invitation as used — on the file as it is now, under its lock.
|
|
147
180
|
try {
|
|
148
|
-
await
|
|
181
|
+
await withInvitationsLock(async () => {
|
|
182
|
+
const current = await readInvitations();
|
|
183
|
+
// The token found it (checked above); it names this invitation alone.
|
|
184
|
+
const accepted = current.find((inv) => inv.token === invitation.token);
|
|
185
|
+
if (!accepted) return;
|
|
186
|
+
accepted.used = true;
|
|
187
|
+
accepted.acceptedAt = new Date().toISOString();
|
|
188
|
+
await writeInvitations(current);
|
|
189
|
+
});
|
|
149
190
|
} catch (err) {
|
|
150
191
|
request.log.warn(err, 'Failed to update invitation status (user was created)');
|
|
151
192
|
}
|
|
@@ -8,10 +8,33 @@
|
|
|
8
8
|
* forwarding needed.
|
|
9
9
|
*
|
|
10
10
|
* Admin-only (lamaste-serverd's mTLS + roleGuard protect these routes).
|
|
11
|
+
*
|
|
12
|
+
* Grants and groups decide who is in an agent-hosted plugin's audience
|
|
13
|
+
* (slice 6): a plugin grant whose context names `roles` is an entitlement,
|
|
14
|
+
* checked against the roles the plugin declares and passed on sorted
|
|
15
|
+
* (`checkProxiedGrant`), and every grant or group change that went through
|
|
16
|
+
* recomputes the audiences. A grant or a group membership is given to a
|
|
17
|
+
* username holding the users lock (`withUsernamesForGrants`), so no
|
|
18
|
+
* deletion races it: a username a removed user held and nobody holds now is
|
|
19
|
+
* refused (409 `username_deleted`), and whatever a removed user who held it
|
|
20
|
+
* left in Gatekeeper is cleared first (503 `gatekeeper_unavailable` when it
|
|
21
|
+
* cannot be) — a reused username inherits nothing. The access log is purged
|
|
22
|
+
* for one username by POST, its name in the body (never in a URL, which
|
|
23
|
+
* logs keep).
|
|
11
24
|
*/
|
|
12
25
|
|
|
26
|
+
import { recomputeAudiences } from '../../lib/audience.js';
|
|
27
|
+
import { withUsernamesForGrants } from '../../lib/directory.js';
|
|
28
|
+
import { checkProxiedGrant } from '../../lib/entitlements.js';
|
|
29
|
+
import { actorFromRequest } from '../../lib/actor.js';
|
|
13
30
|
import { gatekeeperRequest } from '../../lib/gatekeeper-client.js';
|
|
14
31
|
|
|
32
|
+
/** Gatekeeper's `path`, with the request's query string when it has one. */
|
|
33
|
+
function withQuery(path, request) {
|
|
34
|
+
const qs = new URLSearchParams(request.query).toString();
|
|
35
|
+
return qs ? `${path}?${qs}` : path;
|
|
36
|
+
}
|
|
37
|
+
|
|
15
38
|
export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
16
39
|
// All gatekeeper management routes are admin-only
|
|
17
40
|
fastify.addHook('preHandler', fastify.requireRole(['admin']));
|
|
@@ -32,6 +55,39 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
32
55
|
};
|
|
33
56
|
};
|
|
34
57
|
|
|
58
|
+
/** When Gatekeeper took a grant or group change, the plugin audiences follow it. */
|
|
59
|
+
const followAudiences = async (request, answer) => {
|
|
60
|
+
if (answer.statusCode < 400) await recomputeAudiences({ logger: request.log });
|
|
61
|
+
return answer;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A grant or group change: forwarded, and when Gatekeeper took it, the
|
|
66
|
+
* plugin audiences follow it before the answer goes back.
|
|
67
|
+
*/
|
|
68
|
+
const proxyChange = async (request, method, gatekeeperPath, body) =>
|
|
69
|
+
followAudiences(request, await proxy(method, gatekeeperPath, body));
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Give something to `usernames` in Gatekeeper: forwarded holding the
|
|
73
|
+
* users lock, after the usernames' checks (`withUsernamesForGrants`); a
|
|
74
|
+
* refusal of those checks is the answer. The audiences follow.
|
|
75
|
+
*/
|
|
76
|
+
const proxyGive = async (request, usernames, method, gatekeeperPath, body) => {
|
|
77
|
+
let answer;
|
|
78
|
+
try {
|
|
79
|
+
answer = await withUsernamesForGrants(
|
|
80
|
+
usernames,
|
|
81
|
+
{ actor: actorFromRequest(request), logger: request.log },
|
|
82
|
+
() => proxy(method, gatekeeperPath, body),
|
|
83
|
+
);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (!Number.isInteger(err.statusCode)) throw err;
|
|
86
|
+
return { statusCode: err.statusCode, data: { error: err.message, code: err.code } };
|
|
87
|
+
}
|
|
88
|
+
return followAudiences(request, answer);
|
|
89
|
+
};
|
|
90
|
+
|
|
35
91
|
// --- Groups ---
|
|
36
92
|
|
|
37
93
|
fastify.get('/gatekeeper/groups', async (_request, reply) => {
|
|
@@ -40,7 +96,7 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
40
96
|
});
|
|
41
97
|
|
|
42
98
|
fastify.post('/gatekeeper/groups', async (request, reply) => {
|
|
43
|
-
const { statusCode, data } = await
|
|
99
|
+
const { statusCode, data } = await proxyChange(request, 'POST', '/api/groups', request.body);
|
|
44
100
|
return reply.code(statusCode).send(data);
|
|
45
101
|
});
|
|
46
102
|
|
|
@@ -52,7 +108,8 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
52
108
|
|
|
53
109
|
fastify.patch('/gatekeeper/groups/:name', async (request, reply) => {
|
|
54
110
|
const { name } = request.params;
|
|
55
|
-
const { statusCode, data } = await
|
|
111
|
+
const { statusCode, data } = await proxyChange(
|
|
112
|
+
request,
|
|
56
113
|
'PATCH',
|
|
57
114
|
`/api/groups/${encodeURIComponent(name)}`,
|
|
58
115
|
request.body,
|
|
@@ -62,13 +119,22 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
62
119
|
|
|
63
120
|
fastify.delete('/gatekeeper/groups/:name', async (request, reply) => {
|
|
64
121
|
const { name } = request.params;
|
|
65
|
-
const { statusCode, data } = await
|
|
122
|
+
const { statusCode, data } = await proxyChange(
|
|
123
|
+
request,
|
|
124
|
+
'DELETE',
|
|
125
|
+
`/api/groups/${encodeURIComponent(name)}`,
|
|
126
|
+
);
|
|
66
127
|
return reply.code(statusCode).send(data);
|
|
67
128
|
});
|
|
68
129
|
|
|
69
130
|
fastify.post('/gatekeeper/groups/:name/members', async (request, reply) => {
|
|
70
131
|
const { name } = request.params;
|
|
71
|
-
const
|
|
132
|
+
const usernames = Array.isArray(request.body?.usernames)
|
|
133
|
+
? request.body.usernames.filter((u) => typeof u === 'string')
|
|
134
|
+
: [];
|
|
135
|
+
const { statusCode, data } = await proxyGive(
|
|
136
|
+
request,
|
|
137
|
+
usernames,
|
|
72
138
|
'POST',
|
|
73
139
|
`/api/groups/${encodeURIComponent(name)}/members`,
|
|
74
140
|
request.body,
|
|
@@ -78,7 +144,8 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
78
144
|
|
|
79
145
|
fastify.delete('/gatekeeper/groups/:name/members/:username', async (request, reply) => {
|
|
80
146
|
const { name, username } = request.params;
|
|
81
|
-
const { statusCode, data } = await
|
|
147
|
+
const { statusCode, data } = await proxyChange(
|
|
148
|
+
request,
|
|
82
149
|
'DELETE',
|
|
83
150
|
`/api/groups/${encodeURIComponent(name)}/members/${encodeURIComponent(username)}`,
|
|
84
151
|
);
|
|
@@ -88,14 +155,29 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
88
155
|
// --- Grants ---
|
|
89
156
|
|
|
90
157
|
fastify.get('/gatekeeper/grants', async (request, reply) => {
|
|
91
|
-
const
|
|
92
|
-
const grantPath = qs ? `/api/grants?${qs}` : '/api/grants';
|
|
93
|
-
const { statusCode, data } = await proxy('GET', grantPath);
|
|
158
|
+
const { statusCode, data } = await proxy('GET', withQuery('/api/grants', request));
|
|
94
159
|
return reply.code(statusCode).send(data);
|
|
95
160
|
});
|
|
96
161
|
|
|
162
|
+
// A plugin grant with `context.roles` is an entitlement: its roles must be
|
|
163
|
+
// the plugin's (400 otherwise), and go on sorted, without repeats.
|
|
97
164
|
fastify.post('/gatekeeper/grants', async (request, reply) => {
|
|
98
|
-
|
|
165
|
+
let body;
|
|
166
|
+
try {
|
|
167
|
+
body = await checkProxiedGrant(request.body);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (!Number.isInteger(err.statusCode)) throw err;
|
|
170
|
+
return reply.code(err.statusCode).send({
|
|
171
|
+
error: err.message,
|
|
172
|
+
...(err.code ? { code: err.code } : {}),
|
|
173
|
+
...(Array.isArray(err.unknown) ? { unknown: err.unknown } : {}),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const usernames =
|
|
177
|
+
body?.principalType === 'user' && typeof body.principalId === 'string'
|
|
178
|
+
? [body.principalId]
|
|
179
|
+
: [];
|
|
180
|
+
const { statusCode, data } = await proxyGive(request, usernames, 'POST', '/api/grants', body);
|
|
99
181
|
return reply.code(statusCode).send(data);
|
|
100
182
|
});
|
|
101
183
|
|
|
@@ -107,7 +189,8 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
107
189
|
|
|
108
190
|
fastify.delete('/gatekeeper/grants/:grantId', async (request, reply) => {
|
|
109
191
|
const { grantId } = request.params;
|
|
110
|
-
const { statusCode, data } = await
|
|
192
|
+
const { statusCode, data } = await proxyChange(
|
|
193
|
+
request,
|
|
111
194
|
'DELETE',
|
|
112
195
|
`/api/grants/${encodeURIComponent(grantId)}`,
|
|
113
196
|
);
|
|
@@ -117,9 +200,11 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
117
200
|
// Bulk-revoke every grant for a principal. Forwarded to gatekeeper as
|
|
118
201
|
// DELETE /api/grants?principalType=...&principalId=...
|
|
119
202
|
fastify.delete('/gatekeeper/grants', async (request, reply) => {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
203
|
+
const { statusCode, data } = await proxyChange(
|
|
204
|
+
request,
|
|
205
|
+
'DELETE',
|
|
206
|
+
withQuery('/api/grants', request),
|
|
207
|
+
);
|
|
123
208
|
return reply.code(statusCode).send(data);
|
|
124
209
|
});
|
|
125
210
|
|
|
@@ -147,14 +232,20 @@ export default async function gatekeeperProxyRoutes(fastify, _opts) {
|
|
|
147
232
|
});
|
|
148
233
|
|
|
149
234
|
fastify.get('/gatekeeper/access-log', async (request, reply) => {
|
|
150
|
-
const
|
|
151
|
-
const logPath = qs ? `/api/access-log?${qs}` : '/api/access-log';
|
|
152
|
-
const { statusCode, data } = await proxy('GET', logPath);
|
|
235
|
+
const { statusCode, data } = await proxy('GET', withQuery('/api/access-log', request));
|
|
153
236
|
return reply.code(statusCode).send(data);
|
|
154
237
|
});
|
|
155
238
|
|
|
239
|
+
// DELETE clears the whole log. One username's rows (before a time, when
|
|
240
|
+
// given) are purged by POST with `{ username, before? }` in the body — a
|
|
241
|
+
// username in a query string would be in every access log on the way.
|
|
156
242
|
fastify.delete('/gatekeeper/access-log', async (_request, reply) => {
|
|
157
243
|
const { statusCode, data } = await proxy('DELETE', '/api/access-log');
|
|
158
244
|
return reply.code(statusCode).send(data);
|
|
159
245
|
});
|
|
246
|
+
|
|
247
|
+
fastify.post('/gatekeeper/access-log/purge', async (request, reply) => {
|
|
248
|
+
const { statusCode, data } = await proxy('POST', '/api/access-log/purge', request.body);
|
|
249
|
+
return reply.code(statusCode).send(data);
|
|
250
|
+
});
|
|
160
251
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user directory (slice 6), admin only:
|
|
3
|
+
*
|
|
4
|
+
* GET /identity/directory every user the directory knows —
|
|
5
|
+
* tombstones and erased ones too — with
|
|
6
|
+
* the plugins that have not yet
|
|
7
|
+
* acknowledged an erasure (those the
|
|
8
|
+
* feed does not reach now marked)
|
|
9
|
+
* POST /identity/directory/:userId/erase erase a removed user
|
|
10
|
+
*
|
|
11
|
+
* Business logic lives in `lib/directory.js` and `lib/identity-sync.js`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { IDENTITY_UUID_REGEX } from '@lamalibre/lamaste';
|
|
16
|
+
import { actorFromRequest } from '../../lib/actor.js';
|
|
17
|
+
import {
|
|
18
|
+
eraseDirectoryUser,
|
|
19
|
+
getDirectoryId,
|
|
20
|
+
listDirectoryUsers,
|
|
21
|
+
listErasures,
|
|
22
|
+
} from '../../lib/directory.js';
|
|
23
|
+
import { pendingErasuresByUser } from '../../lib/identity-sync.js';
|
|
24
|
+
|
|
25
|
+
const UserIdParamSchema = z.object({
|
|
26
|
+
userId: z.string().regex(IDENTITY_UUID_REGEX, 'userId is a directory user id'),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export default async function identityDirectoryRoutes(fastify, _opts) {
|
|
30
|
+
fastify.get('/identity/directory', { preHandler: fastify.requireRole(['admin']) }, async () => {
|
|
31
|
+
const [directoryId, users, erasures, pending] = await Promise.all([
|
|
32
|
+
getDirectoryId(),
|
|
33
|
+
listDirectoryUsers(),
|
|
34
|
+
listErasures(),
|
|
35
|
+
pendingErasuresByUser(),
|
|
36
|
+
]);
|
|
37
|
+
return {
|
|
38
|
+
directoryId,
|
|
39
|
+
users: users.map((user) => {
|
|
40
|
+
const waiting = pending.get(user.userId) ?? [];
|
|
41
|
+
return {
|
|
42
|
+
...user,
|
|
43
|
+
erasure: erasures.get(user.userId) ?? null,
|
|
44
|
+
pendingErasures: waiting.map((p) => p.plugin),
|
|
45
|
+
// Among them, the plugins the feed does not reach now (uninstalled,
|
|
46
|
+
// or no longer declaring identity) whose data an agent keeps.
|
|
47
|
+
pendingErasuresInactive: waiting.filter((p) => p.inactive).map((p) => p.plugin),
|
|
48
|
+
};
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
fastify.post(
|
|
54
|
+
'/identity/directory/:userId/erase',
|
|
55
|
+
{
|
|
56
|
+
preHandler: fastify.requireRole(['admin']),
|
|
57
|
+
// Moderate tier, as deleting a user: destructive, and it calls Gatekeeper.
|
|
58
|
+
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
|
|
59
|
+
},
|
|
60
|
+
async (request, reply) => {
|
|
61
|
+
const { userId } = UserIdParamSchema.parse(request.params);
|
|
62
|
+
try {
|
|
63
|
+
const erased = await eraseDirectoryUser(userId, {
|
|
64
|
+
actor: actorFromRequest(request),
|
|
65
|
+
logger: request.log,
|
|
66
|
+
});
|
|
67
|
+
return { ok: true, ...erased };
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (Number.isInteger(err.statusCode)) {
|
|
70
|
+
return reply
|
|
71
|
+
.code(err.statusCode)
|
|
72
|
+
.send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
|
|
73
|
+
}
|
|
74
|
+
request.log.error(err, 'Failed to erase the user');
|
|
75
|
+
return reply.code(500).send({ error: 'Failed to erase the user' });
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -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
|
},
|