@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,25 +1,34 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_REGEX } from '@lamalibre/lamaste';
|
|
2
3
|
import {
|
|
3
4
|
readUsers,
|
|
4
5
|
readUsersRaw,
|
|
5
6
|
writeUsers,
|
|
6
|
-
reloadAuthelia,
|
|
7
7
|
hashPassword,
|
|
8
8
|
generateTotpSecret,
|
|
9
9
|
writeTotpToDatabase,
|
|
10
10
|
} from '../../lib/authelia.js';
|
|
11
|
-
import { removeGrantsForUser } from '../../lib/user-access.js';
|
|
12
11
|
import { revokeUserRegistryTokens } from '../../lib/registry-credential.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
12
|
+
import { httpError } from '../../lib/app-error.js';
|
|
13
|
+
import { actorFromRequest } from '../../lib/actor.js';
|
|
14
|
+
import { recordAuditBestEffort } from '../../lib/audit.js';
|
|
15
|
+
import {
|
|
16
|
+
ADMIN_GROUP,
|
|
17
|
+
changeUsers,
|
|
18
|
+
directoryUsersByUsername,
|
|
19
|
+
eraseDirectoryUser,
|
|
20
|
+
findDirectoryUser,
|
|
21
|
+
getDirectoryUser,
|
|
22
|
+
usersFileError,
|
|
23
|
+
} from '../../lib/directory.js';
|
|
15
24
|
|
|
16
25
|
const CreateUserSchema = z.object({
|
|
17
26
|
username: z
|
|
18
27
|
.string()
|
|
19
|
-
.min(
|
|
20
|
-
.max(
|
|
28
|
+
.min(USERNAME_MIN_LENGTH)
|
|
29
|
+
.max(USERNAME_MAX_LENGTH)
|
|
21
30
|
.regex(
|
|
22
|
-
|
|
31
|
+
USERNAME_REGEX,
|
|
23
32
|
'Username must start with a lowercase letter or digit and may contain underscores or hyphens',
|
|
24
33
|
),
|
|
25
34
|
displayname: z.string().min(1).max(100),
|
|
@@ -31,9 +40,9 @@ const CreateUserSchema = z.object({
|
|
|
31
40
|
const UsernameParamSchema = z.object({
|
|
32
41
|
username: z
|
|
33
42
|
.string()
|
|
34
|
-
.min(
|
|
35
|
-
.max(
|
|
36
|
-
.regex(
|
|
43
|
+
.min(USERNAME_MIN_LENGTH)
|
|
44
|
+
.max(USERNAME_MAX_LENGTH)
|
|
45
|
+
.regex(USERNAME_REGEX, 'Invalid username format'),
|
|
37
46
|
});
|
|
38
47
|
|
|
39
48
|
const UpdateUserSchema = z
|
|
@@ -42,13 +51,76 @@ const UpdateUserSchema = z
|
|
|
42
51
|
email: z.string().email().optional(),
|
|
43
52
|
password: z.string().min(8).max(128).optional(),
|
|
44
53
|
groups: z.array(z.string()).optional(),
|
|
54
|
+
// Authelia's `disabled: true`: the user cannot sign in, every plugin
|
|
55
|
+
// audience shows them disabled, and the user surface refuses them.
|
|
56
|
+
disabled: z.boolean().optional(),
|
|
45
57
|
})
|
|
46
58
|
.refine((data) => Object.keys(data).length > 0, {
|
|
47
59
|
message: 'At least one field must be provided',
|
|
48
60
|
});
|
|
49
61
|
|
|
62
|
+
const DeleteQuerySchema = z
|
|
63
|
+
.object({
|
|
64
|
+
// Erase the tombstone at once: what the directory, invitations and
|
|
65
|
+
// Gatekeeper's access-request log keep of the user goes too.
|
|
66
|
+
erase: z.enum(['true', 'false']).optional(),
|
|
67
|
+
})
|
|
68
|
+
.strict();
|
|
69
|
+
|
|
70
|
+
/** users.yml, or a 500 the route answers with (its cause logged without the file's content). */
|
|
71
|
+
async function readUsersOrFail() {
|
|
72
|
+
try {
|
|
73
|
+
return await readUsersRaw();
|
|
74
|
+
} catch (err) {
|
|
75
|
+
throw httpError('Failed to read user database', 500, { cause: usersFileError(err) });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function writeUsersOrFail(usersData) {
|
|
80
|
+
try {
|
|
81
|
+
await writeUsers(usersData);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
throw httpError('Failed to update user database', 500, { cause: err });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The user's groups as a sorted list (users.yml may hold none). */
|
|
88
|
+
function groupsOf(entry) {
|
|
89
|
+
return Array.isArray(entry?.groups) ? [...entry.groups].sort() : [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Whether two lists hold the same strings. */
|
|
93
|
+
function sameList(a, b) {
|
|
94
|
+
return a.length === b.length && a.every((value, i) => value === b[i]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** An enabled member of the administrators' group. */
|
|
98
|
+
function isEnabledAdmin(entry) {
|
|
99
|
+
return (
|
|
100
|
+
Array.isArray(entry?.groups) && entry.groups.includes(ADMIN_GROUP) && entry.disabled !== true
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Answer a refusal a users change threw (an error with `statusCode`: its
|
|
106
|
+
* message and `code`), or a 500 for anything else — logged either way when
|
|
107
|
+
* it is the panel's own failure.
|
|
108
|
+
*/
|
|
109
|
+
function sendFailure(request, reply, err, fallback) {
|
|
110
|
+
if (Number.isInteger(err.statusCode)) {
|
|
111
|
+
if (err.statusCode >= 500) request.log.error(err.cause ?? err, err.message);
|
|
112
|
+
return reply
|
|
113
|
+
.code(err.statusCode)
|
|
114
|
+
.send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
|
|
115
|
+
}
|
|
116
|
+
request.log.error(err, fallback);
|
|
117
|
+
return reply.code(500).send({ error: fallback });
|
|
118
|
+
}
|
|
119
|
+
|
|
50
120
|
export default async function usersRoutes(fastify, _opts) {
|
|
51
|
-
// GET /api/users — list all users (no sensitive fields)
|
|
121
|
+
// GET /api/users — list all users (no sensitive fields): users.yml's, with
|
|
122
|
+
// each one's directory id (null until the directory has seen them, within
|
|
123
|
+
// a minute) and status (`active` or `disabled`)
|
|
52
124
|
fastify.get(
|
|
53
125
|
'/users',
|
|
54
126
|
{
|
|
@@ -57,8 +129,18 @@ export default async function usersRoutes(fastify, _opts) {
|
|
|
57
129
|
async (request, reply) => {
|
|
58
130
|
try {
|
|
59
131
|
const users = await readUsers();
|
|
60
|
-
const
|
|
61
|
-
|
|
132
|
+
const directory = await directoryUsersByUsername();
|
|
133
|
+
const listed = users
|
|
134
|
+
.map((user) => {
|
|
135
|
+
const known = directory.get(user.username);
|
|
136
|
+
return {
|
|
137
|
+
...user,
|
|
138
|
+
userId: known?.userId ?? null,
|
|
139
|
+
status: known?.status ?? (user.disabled ? 'disabled' : 'active'),
|
|
140
|
+
};
|
|
141
|
+
})
|
|
142
|
+
.sort((a, b) => a.username.localeCompare(b.username));
|
|
143
|
+
return { users: listed };
|
|
62
144
|
} catch (err) {
|
|
63
145
|
request.log.error(err, 'Failed to read user database');
|
|
64
146
|
return reply.code(500).send({ error: 'Failed to read user database' });
|
|
@@ -78,16 +160,13 @@ export default async function usersRoutes(fastify, _opts) {
|
|
|
78
160
|
async (request, reply) => {
|
|
79
161
|
const body = CreateUserSchema.parse(request.body);
|
|
80
162
|
|
|
81
|
-
let usersData;
|
|
82
163
|
try {
|
|
83
|
-
|
|
164
|
+
// Refused before a password is hashed; checked again under the lock.
|
|
165
|
+
if ((await readUsersOrFail()).users[body.username]) {
|
|
166
|
+
return reply.code(409).send({ error: 'Username already exists' });
|
|
167
|
+
}
|
|
84
168
|
} catch (err) {
|
|
85
|
-
request
|
|
86
|
-
return reply.code(500).send({ error: 'Failed to read user database' });
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (usersData.users[body.username]) {
|
|
90
|
-
return reply.code(409).send({ error: 'Username already exists' });
|
|
169
|
+
return sendFailure(request, reply, err, 'Failed to read user database');
|
|
91
170
|
}
|
|
92
171
|
|
|
93
172
|
let hash;
|
|
@@ -98,39 +177,54 @@ export default async function usersRoutes(fastify, _opts) {
|
|
|
98
177
|
return reply.code(500).send({ error: 'Failed to hash password' });
|
|
99
178
|
}
|
|
100
179
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
email: body.email,
|
|
104
|
-
password: hash,
|
|
105
|
-
groups: body.groups,
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
await writeUsers(usersData);
|
|
110
|
-
} catch (err) {
|
|
111
|
-
request.log.error(err, 'Failed to update user database');
|
|
112
|
-
return reply.code(500).send({ error: 'Failed to update user database' });
|
|
113
|
-
}
|
|
114
|
-
|
|
180
|
+
let warnings;
|
|
181
|
+
let persisted;
|
|
115
182
|
try {
|
|
116
|
-
await
|
|
183
|
+
({ warnings, persisted } = await changeUsers(
|
|
184
|
+
async () => {
|
|
185
|
+
const usersData = await readUsersOrFail();
|
|
186
|
+
if (usersData.users[body.username]) throw httpError('Username already exists', 409);
|
|
187
|
+
usersData.users[body.username] = {
|
|
188
|
+
displayname: body.displayname,
|
|
189
|
+
email: body.email,
|
|
190
|
+
password: hash,
|
|
191
|
+
groups: body.groups,
|
|
192
|
+
};
|
|
193
|
+
await writeUsersOrFail(usersData);
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
actor: actorFromRequest(request),
|
|
197
|
+
logger: request.log,
|
|
198
|
+
// A removed user's grants, groups and second factors under the
|
|
199
|
+
// username go first; a username with an OIDC identity is refused.
|
|
200
|
+
creates: [body.username],
|
|
201
|
+
expect: (users) => users[body.username]?.password === hash,
|
|
202
|
+
},
|
|
203
|
+
));
|
|
117
204
|
} catch (err) {
|
|
118
|
-
request
|
|
205
|
+
return sendFailure(request, reply, err, 'Failed to update user database');
|
|
119
206
|
}
|
|
120
207
|
|
|
121
|
-
|
|
122
|
-
|
|
208
|
+
const created = await findDirectoryUser(body.username);
|
|
209
|
+
return reply.code(persisted ? 201 : 200).send({
|
|
210
|
+
ok: persisted,
|
|
211
|
+
...(persisted ? {} : { persisted: false }),
|
|
123
212
|
user: {
|
|
124
213
|
username: body.username,
|
|
125
214
|
displayname: body.displayname,
|
|
126
215
|
email: body.email,
|
|
127
216
|
groups: body.groups,
|
|
217
|
+
disabled: false,
|
|
218
|
+
userId: created?.userId ?? null,
|
|
219
|
+
status: 'active',
|
|
128
220
|
},
|
|
221
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
129
222
|
});
|
|
130
223
|
},
|
|
131
224
|
);
|
|
132
225
|
|
|
133
|
-
// PUT /api/users/:username — update a user
|
|
226
|
+
// PUT /api/users/:username — update a user; `disabled: true` disables
|
|
227
|
+
// them (the last enabled administrator cannot be), `false` enables them
|
|
134
228
|
fastify.put(
|
|
135
229
|
'/users/:username',
|
|
136
230
|
{
|
|
@@ -144,83 +238,130 @@ export default async function usersRoutes(fastify, _opts) {
|
|
|
144
238
|
const { username } = UsernameParamSchema.parse(request.params);
|
|
145
239
|
const body = UpdateUserSchema.parse(request.body);
|
|
146
240
|
|
|
147
|
-
let
|
|
148
|
-
try {
|
|
149
|
-
usersData = await readUsersRaw();
|
|
150
|
-
} catch (err) {
|
|
151
|
-
request.log.error(err, 'Failed to read user database');
|
|
152
|
-
return reply.code(500).send({ error: 'Failed to read user database' });
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (!usersData.users[username]) {
|
|
156
|
-
return reply.code(404).send({ error: 'User not found' });
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const user = usersData.users[username];
|
|
160
|
-
const groupsBefore = Array.isArray(user.groups) ? [...user.groups] : [];
|
|
161
|
-
|
|
162
|
-
if (body.displayname !== undefined) {
|
|
163
|
-
user.displayname = body.displayname;
|
|
164
|
-
}
|
|
165
|
-
if (body.email !== undefined) {
|
|
166
|
-
user.email = body.email;
|
|
167
|
-
}
|
|
168
|
-
if (body.groups !== undefined) {
|
|
169
|
-
user.groups = body.groups;
|
|
170
|
-
}
|
|
241
|
+
let passwordHash;
|
|
171
242
|
if (body.password !== undefined) {
|
|
172
243
|
try {
|
|
173
|
-
|
|
244
|
+
passwordHash = await hashPassword(body.password);
|
|
174
245
|
} catch (err) {
|
|
175
246
|
request.log.error(err, 'Failed to hash password');
|
|
176
247
|
return reply.code(500).send({ error: 'Failed to hash password' });
|
|
177
248
|
}
|
|
178
249
|
}
|
|
179
250
|
|
|
251
|
+
/** Whether users.yml's entry holds every field this request set. */
|
|
252
|
+
const intended = (entry) =>
|
|
253
|
+
entry !== undefined &&
|
|
254
|
+
entry !== null &&
|
|
255
|
+
(body.displayname === undefined || entry.displayname === body.displayname) &&
|
|
256
|
+
(body.email === undefined || entry.email === body.email) &&
|
|
257
|
+
(body.groups === undefined || sameList(groupsOf(entry), [...body.groups].sort())) &&
|
|
258
|
+
(passwordHash === undefined || entry.password === passwordHash) &&
|
|
259
|
+
(body.disabled === undefined || (entry.disabled === true) === body.disabled);
|
|
260
|
+
|
|
261
|
+
let outcome;
|
|
262
|
+
let before = null;
|
|
180
263
|
try {
|
|
181
|
-
await
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
264
|
+
outcome = await changeUsers(
|
|
265
|
+
async () => {
|
|
266
|
+
const usersData = await readUsersOrFail();
|
|
267
|
+
const user = usersData.users[username];
|
|
268
|
+
if (!user) throw httpError('User not found', 404);
|
|
269
|
+
// As it was before this request (a second attempt finds it changed already).
|
|
270
|
+
before ??= { groups: groupsOf(user), disabled: user.disabled === true };
|
|
271
|
+
if (body.disabled === true && isEnabledAdmin(user)) {
|
|
272
|
+
const enabledAdmins = Object.values(usersData.users).filter(isEnabledAdmin).length;
|
|
273
|
+
if (enabledAdmins <= 1) {
|
|
274
|
+
throw httpError('Cannot disable the last enabled administrator', 400, {
|
|
275
|
+
code: 'last_admin',
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (body.displayname !== undefined) user.displayname = body.displayname;
|
|
281
|
+
if (body.email !== undefined) user.email = body.email;
|
|
282
|
+
if (body.groups !== undefined) user.groups = body.groups;
|
|
283
|
+
if (passwordHash !== undefined) user.password = passwordHash;
|
|
284
|
+
if (body.disabled === true) user.disabled = true;
|
|
285
|
+
if (body.disabled === false) delete user.disabled;
|
|
286
|
+
|
|
287
|
+
await writeUsersOrFail(usersData);
|
|
288
|
+
return user;
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
actor: actorFromRequest(request),
|
|
292
|
+
logger: request.log,
|
|
293
|
+
// Authelia's own password reset may rewrite users.yml under the
|
|
294
|
+
// change: it is checked after the restart, and made once more.
|
|
295
|
+
expect: (users) => intended(users[username]),
|
|
296
|
+
},
|
|
297
|
+
);
|
|
189
298
|
} catch (err) {
|
|
190
|
-
request
|
|
299
|
+
return sendFailure(request, reply, err, 'Failed to update user database');
|
|
191
300
|
}
|
|
192
301
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
302
|
+
const user = outcome.result;
|
|
303
|
+
const disabled = user.disabled === true;
|
|
304
|
+
const warnings = [...outcome.warnings];
|
|
305
|
+
const known = await findDirectoryUser(username);
|
|
306
|
+
const userId = known?.userId ?? null;
|
|
307
|
+
|
|
308
|
+
// Disabling is offboarding, which the change did as the directory saw
|
|
309
|
+
// the user become disabled (`changeUsers`): sessions ended, registry
|
|
310
|
+
// tokens revoked. Registry scopes are granted at login from feria-*
|
|
311
|
+
// groups too: losing one must not leave a valid token behind either.
|
|
312
|
+
const groupsAfter = groupsOf(user);
|
|
313
|
+
const groupsChanged = body.groups !== undefined && !sameList(before.groups, groupsAfter);
|
|
314
|
+
if (groupsChanged && !(disabled && !before.disabled)) {
|
|
198
315
|
try {
|
|
199
316
|
await revokeUserRegistryTokens(
|
|
200
317
|
username,
|
|
201
|
-
{ before:
|
|
318
|
+
{ before: before.groups, after: groupsAfter },
|
|
202
319
|
request.log,
|
|
320
|
+
{ userId },
|
|
203
321
|
);
|
|
204
322
|
} catch (err) {
|
|
205
|
-
request.log.warn({
|
|
206
|
-
warnings.push(
|
|
323
|
+
request.log.warn({ userId, err: err.message }, 'Failed to revoke registry tokens');
|
|
324
|
+
warnings.push({
|
|
325
|
+
step: 'registry',
|
|
326
|
+
message: `Registry tokens were not revoked: ${err.message}`,
|
|
327
|
+
});
|
|
207
328
|
}
|
|
208
329
|
}
|
|
209
330
|
|
|
331
|
+
// The directory audits status and attributes; these are the users API's own.
|
|
332
|
+
const audit = (action, details = {}) =>
|
|
333
|
+
recordAuditBestEffort(
|
|
334
|
+
{ actor: actorFromRequest(request), action, details: { userId, ...details } },
|
|
335
|
+
request.log,
|
|
336
|
+
);
|
|
337
|
+
if (passwordHash !== undefined) await audit('user.password.set');
|
|
338
|
+
if (groupsChanged) {
|
|
339
|
+
await audit('user.groups.changed', {
|
|
340
|
+
added: groupsAfter.filter((g) => !before.groups.includes(g)),
|
|
341
|
+
removed: before.groups.filter((g) => !groupsAfter.includes(g)),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
210
345
|
return {
|
|
211
|
-
ok:
|
|
346
|
+
ok: outcome.persisted,
|
|
212
347
|
user: {
|
|
213
348
|
username,
|
|
214
349
|
displayname: user.displayname,
|
|
215
350
|
email: user.email,
|
|
216
351
|
groups: user.groups || [],
|
|
352
|
+
disabled,
|
|
353
|
+
userId,
|
|
354
|
+
status: disabled ? 'disabled' : 'active',
|
|
217
355
|
},
|
|
356
|
+
...(outcome.persisted ? {} : { persisted: false }),
|
|
218
357
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
219
358
|
};
|
|
220
359
|
},
|
|
221
360
|
);
|
|
222
361
|
|
|
223
|
-
// DELETE /api/users/:username — delete a user
|
|
362
|
+
// DELETE /api/users/:username?erase= — delete a user. The directory keeps
|
|
363
|
+
// a tombstone (the id and the last known name and address, `removed`);
|
|
364
|
+
// `erase=true` scrubs it at once (see POST /api/identity/directory/:userId/erase)
|
|
224
365
|
fastify.delete(
|
|
225
366
|
'/users/:username',
|
|
226
367
|
{
|
|
@@ -232,139 +373,72 @@ export default async function usersRoutes(fastify, _opts) {
|
|
|
232
373
|
},
|
|
233
374
|
async (request, reply) => {
|
|
234
375
|
const { username } = UsernameParamSchema.parse(request.params);
|
|
376
|
+
const { erase } = DeleteQuerySchema.parse(request.query ?? {});
|
|
377
|
+
const actor = actorFromRequest(request);
|
|
235
378
|
|
|
236
|
-
let
|
|
379
|
+
let outcome;
|
|
237
380
|
try {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
try {
|
|
256
|
-
await writeUsers(usersData);
|
|
257
|
-
} catch (err) {
|
|
258
|
-
request.log.error(err, 'Failed to update user database');
|
|
259
|
-
return reply.code(500).send({ error: 'Failed to update user database' });
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
try {
|
|
263
|
-
await reloadAuthelia();
|
|
264
|
-
} catch (err) {
|
|
265
|
-
request.log.warn(err, 'Failed to reload Authelia after user deletion');
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// ---------------------------------------------------------------
|
|
269
|
-
// Cascade cleanup. The user is already gone from users.yml; from
|
|
270
|
-
// here on we never roll back. Failures collect into `warnings` so
|
|
271
|
-
// the admin sees a partial-success response and can investigate.
|
|
272
|
-
// ---------------------------------------------------------------
|
|
273
|
-
const warnings = [];
|
|
274
|
-
|
|
275
|
-
// 0. Registry (Feria) tokens the user holds — their groups are gone.
|
|
276
|
-
try {
|
|
277
|
-
await revokeUserRegistryTokens(username, { deleted: true }, request.log);
|
|
278
|
-
} catch (err) {
|
|
279
|
-
request.log.warn(
|
|
280
|
-
{ username, err: err.message },
|
|
281
|
-
'Cascade: registry token revocation failed',
|
|
381
|
+
outcome = await changeUsers(
|
|
382
|
+
async () => {
|
|
383
|
+
const usersData = await readUsersOrFail();
|
|
384
|
+
if (!usersData.users[username]) throw httpError('User not found', 404);
|
|
385
|
+
if (Object.keys(usersData.users).length <= 1) {
|
|
386
|
+
throw httpError('Cannot delete the last user', 400);
|
|
387
|
+
}
|
|
388
|
+
delete usersData.users[username];
|
|
389
|
+
await writeUsersOrFail(usersData);
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
actor,
|
|
393
|
+
logger: request.log,
|
|
394
|
+
deleted: [username],
|
|
395
|
+
expect: (users) => !Object.hasOwn(users, username),
|
|
396
|
+
},
|
|
282
397
|
);
|
|
283
|
-
warnings.push(`registry tokens were not revoked: ${err.message}`);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// 1. Legacy user-plugin-access.json grants (pre-gatekeeper migration)
|
|
287
|
-
try {
|
|
288
|
-
const removed = await removeGrantsForUser(username, request.log);
|
|
289
|
-
if (removed > 0) {
|
|
290
|
-
request.log.info(
|
|
291
|
-
{ username, removed },
|
|
292
|
-
'Cascade: removed legacy user-plugin-access grants',
|
|
293
|
-
);
|
|
294
|
-
}
|
|
295
398
|
} catch (err) {
|
|
296
|
-
request
|
|
297
|
-
{ err, username },
|
|
298
|
-
'Cascade: failed to remove legacy user-plugin-access grants',
|
|
299
|
-
);
|
|
300
|
-
warnings.push({
|
|
301
|
-
step: 'user-plugin-access',
|
|
302
|
-
message: `Failed to remove legacy grants: ${err.message}`,
|
|
303
|
-
});
|
|
399
|
+
return sendFailure(request, reply, err, 'Failed to update user database');
|
|
304
400
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
} else if (result.reason === 'unreachable' || result.reason === 'no-secret') {
|
|
324
|
-
request.log.warn(
|
|
325
|
-
{ username, reason: result.reason },
|
|
326
|
-
'Cascade: gatekeeper unreachable — grants for deleted user remain in access-grants.json',
|
|
327
|
-
);
|
|
401
|
+
const userId =
|
|
402
|
+
outcome.directory?.removed.find((removed) => removed.username === username)?.userId ?? null;
|
|
403
|
+
|
|
404
|
+
// The user was offboarded as the directory saw them go (`changeUsers`):
|
|
405
|
+
// desktop sessions ended, registry tokens revoked, legacy
|
|
406
|
+
// user-plugin-access grants removed, Gatekeeper's grants and group
|
|
407
|
+
// memberships under the username dropped, Authelia's second factors
|
|
408
|
+
// deleted. What failed is among the warnings; the tombstone keeps a
|
|
409
|
+
// Gatekeeper cleanup pending until it succeeds.
|
|
410
|
+
const warnings = [...outcome.warnings];
|
|
411
|
+
|
|
412
|
+
// Erase the tombstone, when asked to. Its failure leaves the tombstone
|
|
413
|
+
// in place, to be erased later by its id.
|
|
414
|
+
let erased = false;
|
|
415
|
+
if (erase === 'true') {
|
|
416
|
+
if (userId === null) {
|
|
328
417
|
warnings.push({
|
|
329
|
-
step: '
|
|
418
|
+
step: 'erase',
|
|
330
419
|
message:
|
|
331
|
-
'
|
|
420
|
+
'The user directory holds no tombstone for this user — nothing was erased; erase it from the directory once it appears there',
|
|
332
421
|
});
|
|
333
422
|
} else {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
)
|
|
338
|
-
|
|
339
|
-
step: '
|
|
340
|
-
|
|
341
|
-
});
|
|
423
|
+
try {
|
|
424
|
+
await eraseDirectoryUser(userId, { actor, logger: request.log });
|
|
425
|
+
erased = true;
|
|
426
|
+
} catch (err) {
|
|
427
|
+
request.log.warn({ userId, err: err.message }, 'Cascade: erasing the user failed');
|
|
428
|
+
warnings.push({ step: 'erase', message: err.message });
|
|
429
|
+
}
|
|
342
430
|
}
|
|
343
|
-
} catch (err) {
|
|
344
|
-
request.log.warn({ err, username }, 'Cascade: unexpected failure calling gatekeeper');
|
|
345
|
-
warnings.push({
|
|
346
|
-
step: 'gatekeeper',
|
|
347
|
-
message: `Unexpected failure: ${err.message}`,
|
|
348
|
-
});
|
|
349
431
|
}
|
|
350
432
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
);
|
|
361
|
-
warnings.push({
|
|
362
|
-
step: 'session-epoch',
|
|
363
|
-
message: `Failed to invalidate live sessions: ${err.message}. Old session tokens for this username may still validate until their natural expiry.`,
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
const response = { ok: true };
|
|
433
|
+
const tombstone = userId ? await getDirectoryUser(userId) : null;
|
|
434
|
+
const response = {
|
|
435
|
+
ok: outcome.persisted,
|
|
436
|
+
...(outcome.persisted ? {} : { persisted: false }),
|
|
437
|
+
userId,
|
|
438
|
+
// Gatekeeper dropped the grants and group memberships under the username.
|
|
439
|
+
principalCleared: tombstone?.principalClearedAt != null,
|
|
440
|
+
...(erase === 'true' ? { erased } : {}),
|
|
441
|
+
};
|
|
368
442
|
if (warnings.length > 0) {
|
|
369
443
|
response.warnings = warnings;
|
|
370
444
|
}
|
|
@@ -383,10 +457,9 @@ export default async function usersRoutes(fastify, _opts) {
|
|
|
383
457
|
|
|
384
458
|
let usersData;
|
|
385
459
|
try {
|
|
386
|
-
usersData = await
|
|
460
|
+
usersData = await readUsersOrFail();
|
|
387
461
|
} catch (err) {
|
|
388
|
-
request
|
|
389
|
-
return reply.code(500).send({ error: 'Failed to read user database' });
|
|
462
|
+
return sendFailure(request, reply, err, 'Failed to read user database');
|
|
390
463
|
}
|
|
391
464
|
|
|
392
465
|
if (!usersData.users[username]) {
|
|
@@ -402,6 +475,15 @@ export default async function usersRoutes(fastify, _opts) {
|
|
|
402
475
|
return reply.code(500).send({ error: 'Failed to write TOTP configuration' });
|
|
403
476
|
}
|
|
404
477
|
|
|
478
|
+
const known = await findDirectoryUser(username);
|
|
479
|
+
await recordAuditBestEffort(
|
|
480
|
+
{
|
|
481
|
+
actor: actorFromRequest(request),
|
|
482
|
+
action: 'user.totp.reset',
|
|
483
|
+
details: { userId: known?.userId ?? null },
|
|
484
|
+
},
|
|
485
|
+
request.log,
|
|
486
|
+
);
|
|
405
487
|
return { ok: true, totpUri: uri };
|
|
406
488
|
},
|
|
407
489
|
);
|
|
@@ -11,6 +11,9 @@ import pluginRoutes from './management/plugins.js';
|
|
|
11
11
|
import settingsRoutes from './management/settings.js';
|
|
12
12
|
import ticketRoutes from './management/tickets.js';
|
|
13
13
|
import identityRoutes from './management/identity.js';
|
|
14
|
+
import identityDirectoryRoutes from './management/identity-directory.js';
|
|
15
|
+
import pluginIdentityRoutes from './management/plugin-identity.js';
|
|
16
|
+
import pluginWebSurfaceRoutes from './management/plugin-web-surface.js';
|
|
14
17
|
import storageRoutes from './management/storage.js';
|
|
15
18
|
import gatekeeperProxyRoutes from './management/gatekeeper-proxy.js';
|
|
16
19
|
import agentsRoutes from './management/agents.js';
|
|
@@ -33,6 +36,9 @@ export default async function managementRoutes(fastify, _opts) {
|
|
|
33
36
|
await fastify.register(settingsRoutes);
|
|
34
37
|
await fastify.register(ticketRoutes);
|
|
35
38
|
await fastify.register(identityRoutes);
|
|
39
|
+
await fastify.register(identityDirectoryRoutes);
|
|
40
|
+
await fastify.register(pluginIdentityRoutes);
|
|
41
|
+
await fastify.register(pluginWebSurfaceRoutes);
|
|
36
42
|
await fastify.register(storageRoutes);
|
|
37
43
|
await fastify.register(gatekeeperProxyRoutes);
|
|
38
44
|
await fastify.register(agentsRoutes);
|