@lamalibre/create-lamaste 4.0.0 → 4.2.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 +21 -2
- package/src/lib/oidc-issuer-key.js +124 -0
- package/src/lib/relay-services.js +4 -0
- package/src/lib/service-config.js +16 -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 +45 -1
- 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 +1 -1
- 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 +3 -3
- 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/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +6 -5
- package/vendor/docs/03-architecture/management-flow.md +1 -1
- 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 +2 -2
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +111 -105
- package/vendor/docs/04-api-reference/registry-and-oidc.md +207 -0
- 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 -5
- package/vendor/docs/06-reference/config-files.md +103 -45
- 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 +5 -0
- package/vendor/docs/gatekeeper-design.md +8 -8
- package/vendor/docs/identity-directory-design.md +443 -0
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/package.json +1 -1
- package/vendor/server/bin/lamaste-server.js +10 -1
- package/vendor/server/package.json +4 -3
- package/vendor/server/src/commands/plugins.js +200 -158
- package/vendor/server/src/commands/registry-credential.js +142 -0
- package/vendor/server/src/panel-api.js +86 -39
- package/vendor/server-ui/dist/assets/{index-B0nWuUyL.js → index-B5Wp8lu-.js} +26 -26
- package/vendor/server-ui/dist/index.html +1 -1
- package/vendor/serverd/package.json +5 -2
- package/vendor/serverd/src/index.js +11 -0
- package/vendor/serverd/src/lib/authelia.js +11 -0
- package/vendor/serverd/src/lib/migrations/state/0005_plugin_hosting.sql +3 -0
- package/vendor/serverd/src/lib/plugin-oidc.js +233 -0
- package/vendor/serverd/src/lib/plugins.js +37 -5
- package/vendor/serverd/src/lib/registry-credential.js +145 -0
- package/vendor/serverd/src/routes/management/agents.js +81 -0
- package/vendor/serverd/src/routes/management/plugins.js +65 -2
- package/vendor/serverd/src/routes/management/registry-credential.js +170 -0
- package/vendor/serverd/src/routes/management/storage.js +10 -2
- package/vendor/serverd/src/routes/management/users.js +31 -0
- package/vendor/serverd/src/routes/management.js +2 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
# Lamaste Identity Directory — Design Document
|
|
2
|
+
|
|
3
|
+
> **Status:** design, 2026-10-13. Nothing here is implemented yet.
|
|
4
|
+
> **Package:** `@lamalibre/lamaste-identity@4.2.0` (completes the existing SDK).
|
|
5
|
+
> **Companions:** `gatekeeper-design.md` (authorization),
|
|
6
|
+
> `plugin-development/design/agent-hosted-app-plugins.md` (app plugins).
|
|
7
|
+
|
|
8
|
+
## Goal
|
|
9
|
+
|
|
10
|
+
Lamaste becomes the **directory** for every application in the ecosystem, the
|
|
11
|
+
way Active Directory is for a Windows estate:
|
|
12
|
+
|
|
13
|
+
- People exist **once**, in Lamaste. Applications do not create their own
|
|
14
|
+
logins.
|
|
15
|
+
- An application's users are exactly the Lamaste users **entitled** to it.
|
|
16
|
+
The application may keep a local user table — and should, for joins and
|
|
17
|
+
history — but that table is a **projection** of Lamaste's directory, kept
|
|
18
|
+
current by Lamaste, never edited as the source of truth.
|
|
19
|
+
- When a person or an entitlement changes, Lamaste **notifies** the
|
|
20
|
+
application; the application pulls the change and applies it however suits
|
|
21
|
+
its data. A periodic reconciliation catches anything a notification missed.
|
|
22
|
+
- On every request, the application knows **who** is calling, from an
|
|
23
|
+
assertion it can verify — not from a header it has to take on faith.
|
|
24
|
+
|
|
25
|
+
PDKS is the case that shapes this: its screens show employees' names on every
|
|
26
|
+
row. It cannot ask Lamaste for each one; it keeps its own copy and needs that
|
|
27
|
+
copy to follow Lamaste.
|
|
28
|
+
|
|
29
|
+
## What exists today
|
|
30
|
+
|
|
31
|
+
| Piece | State |
|
|
32
|
+
|---|---|
|
|
33
|
+
| User store | Authelia's `users.yml`, keyed by **username**. No stable id. |
|
|
34
|
+
| `GET /api/identity/users`, `/users/:username`, `/groups` | Read `users.yml`; admin or agent with `identity:query`. |
|
|
35
|
+
| `GET /api/identity/self` | Echoes `Remote-*` headers; only meaningful behind Authelia. |
|
|
36
|
+
| SDK `IdentityClient` | `listUsers`, `getUser`, `listGroups` over mTLS. |
|
|
37
|
+
| SDK `parseIdentity` + Fastify plugin | Parses `Remote-User/Name/Email/Groups` headers. |
|
|
38
|
+
| Tunnel vhosts | **Blank** `Remote-User` and `Remote-Groups` before proxying — apps receive no identity. |
|
|
39
|
+
| Entitlements | Two models: per-plugin grants (`user-plugin-access.json`, by username) and Gatekeeper grants (groups → resources). |
|
|
40
|
+
|
|
41
|
+
So the SDK can list users, but nothing gives an application a stable key, a
|
|
42
|
+
scoped user list, a change feed, or a trustworthy identity on a request.
|
|
43
|
+
|
|
44
|
+
## Model
|
|
45
|
+
|
|
46
|
+
### Principals
|
|
47
|
+
|
|
48
|
+
Every user gets an immutable **`userId`** (UUID v4) at creation. Applications
|
|
49
|
+
key everything on `userId`; `username` is a mutable attribute (renames are
|
|
50
|
+
supported and propagate as an update).
|
|
51
|
+
|
|
52
|
+
The directory record Lamaste owns:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
interface DirectoryUser {
|
|
56
|
+
userId: string; // UUID, never reused
|
|
57
|
+
username: string; // login name, unique, may change
|
|
58
|
+
displayName: string;
|
|
59
|
+
email: string;
|
|
60
|
+
groups: string[]; // Lamaste groups (Gatekeeper tier), not raw Authelia groups
|
|
61
|
+
status: 'active' | 'disabled';
|
|
62
|
+
canSignIn: boolean; // false = directory-only person (see "People who never sign in")
|
|
63
|
+
updatedAt: string;
|
|
64
|
+
version: number; // per-record, increments on every change
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Lamaste stores the directory in its SQLite state (`userId`, attributes,
|
|
69
|
+
version), and keeps `users.yml` as the **derived** credential file Authelia
|
|
70
|
+
reads — Lamaste writes it, never parses it as the source of truth.
|
|
71
|
+
|
|
72
|
+
### Entitlements
|
|
73
|
+
|
|
74
|
+
An **entitlement** says a user may use a plugin, optionally with roles:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
interface Entitlement {
|
|
78
|
+
plugin: string; // "pdks", "apsiyon", …
|
|
79
|
+
userId: string;
|
|
80
|
+
roles: string[]; // plugin-declared roles, e.g. ["viewer"], ["manager"]
|
|
81
|
+
source: 'direct' | `group:${string}`;
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Entitlements are computed from Gatekeeper grants (user or group → plugin
|
|
86
|
+
resource, with roles as the grant's context). The older
|
|
87
|
+
`user-plugin-access.json` model is migrated into Gatekeeper grants, so there
|
|
88
|
+
is **one** entitlement model.
|
|
89
|
+
|
|
90
|
+
Plugins declare their roles in the manifest:
|
|
91
|
+
|
|
92
|
+
```jsonc
|
|
93
|
+
"identity": {
|
|
94
|
+
"roles": [
|
|
95
|
+
{ "id": "viewer", "label": "Görüntüleyici" },
|
|
96
|
+
{ "id": "manager", "label": "Yönetici" }
|
|
97
|
+
],
|
|
98
|
+
"attributes": ["displayName", "email"] // what the plugin is allowed to receive; default displayName only
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`attributes` is data minimisation: a plugin receives only the fields it
|
|
103
|
+
declared, and the admin sees them at install.
|
|
104
|
+
|
|
105
|
+
### What a plugin sees
|
|
106
|
+
|
|
107
|
+
A plugin's directory view is its **audience**: the entitled users, each with
|
|
108
|
+
only the declared attributes and the plugin's roles.
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
interface AudienceMember {
|
|
112
|
+
userId: string;
|
|
113
|
+
username: string;
|
|
114
|
+
displayName?: string; // only if declared
|
|
115
|
+
email?: string; // only if declared
|
|
116
|
+
roles: string[]; // this plugin's roles only
|
|
117
|
+
status: 'active' | 'disabled';
|
|
118
|
+
version: number;
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Groups are not passed through raw — a plugin sees its roles, not the
|
|
123
|
+
organisation's group structure.
|
|
124
|
+
|
|
125
|
+
## Change feed
|
|
126
|
+
|
|
127
|
+
### Sequence
|
|
128
|
+
|
|
129
|
+
Every directory or entitlement change appends to a store-wide, monotonically
|
|
130
|
+
increasing **`seq`**, stamped in the same transaction as the change (the
|
|
131
|
+
pattern already proven in PDKS's sync: triggers stamp `seq`, a cursor on
|
|
132
|
+
`seq` also catches rewritten rows). The directory has a **`directoryId`**
|
|
133
|
+
that changes if the store is replaced, telling a subscriber its cursor is
|
|
134
|
+
void.
|
|
135
|
+
|
|
136
|
+
### Pull (the source of truth for subscribers)
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
GET /api/identity/audience?plugin=pdks → { directoryId, seq, members: [...] } full snapshot
|
|
140
|
+
GET /api/identity/audience/changes?plugin=pdks&after=<seq> → { directoryId, seq, changes: [...] } since a cursor
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Each change is the member's full new state or a tombstone:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
type AudienceChange =
|
|
147
|
+
| { seq: number; kind: 'upsert'; member: AudienceMember }
|
|
148
|
+
| { seq: number; kind: 'remove'; userId: string; reason: 'deleted' | 'unentitled' };
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Full state, not diffs, so a subscriber never has to reconstruct a record
|
|
152
|
+
from a chain of patches, and applying a change twice is harmless.
|
|
153
|
+
`changes` is paged (`limit`, default 500) and ordered by `seq`.
|
|
154
|
+
|
|
155
|
+
### Push (a nudge, not the data)
|
|
156
|
+
|
|
157
|
+
Pushing data to subscribers would make Lamaste responsible for delivery and
|
|
158
|
+
ordering to machines behind CGNAT. Instead Lamaste pushes a **notification**
|
|
159
|
+
and the subscriber pulls:
|
|
160
|
+
|
|
161
|
+
- **Agent-hosted app plugins:** the server half calls the agent half's
|
|
162
|
+
`POST /admin/identity/notify { seq }` through the internal plugin tunnel
|
|
163
|
+
(the channel from the app-plugin design). The agent half pulls
|
|
164
|
+
`changes?after=<its cursor>`.
|
|
165
|
+
- **Server-hosted plugins:** an in-process event (`identity.changed`) on the
|
|
166
|
+
plugin host.
|
|
167
|
+
- **Plain agents** (no app plugin): their 30-second sync already contacts the
|
|
168
|
+
panel; the sync response carries the current `seq`, and the SDK pulls when
|
|
169
|
+
it moved.
|
|
170
|
+
|
|
171
|
+
A lost notification costs nothing: the next one, the next sync, or the
|
|
172
|
+
reconciliation carries the cursor forward.
|
|
173
|
+
|
|
174
|
+
### Reconciliation
|
|
175
|
+
|
|
176
|
+
Daily, and at subscriber start-up, the SDK fetches the full audience snapshot
|
|
177
|
+
and compares it with the local projection (by `userId` and `version`). It
|
|
178
|
+
applies differences and logs how many it found — a non-zero count on a
|
|
179
|
+
healthy system is a bug in the change feed and is reported to the panel.
|
|
180
|
+
|
|
181
|
+
## SDK (`@lamalibre/lamaste-identity@4.2.0`)
|
|
182
|
+
|
|
183
|
+
Today's surface stays; three pieces are added.
|
|
184
|
+
|
|
185
|
+
### 1. `IdentitySubscriber` — keeps a local projection current
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
const subscriber = new IdentitySubscriber({
|
|
189
|
+
plugin: 'pdks',
|
|
190
|
+
client, // existing IdentityClient (mTLS)
|
|
191
|
+
cursorStore, // where the subscriber keeps { directoryId, seq }
|
|
192
|
+
apply: async (changes, tx) => { // the plugin decides what to do with each change
|
|
193
|
+
for (const c of changes) {
|
|
194
|
+
if (c.kind === 'upsert') upsertPerson(tx, c.member);
|
|
195
|
+
else markRemoved(tx, c.userId, c.reason);
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
reconcileEvery: '24h',
|
|
199
|
+
});
|
|
200
|
+
await subscriber.start(); // initial snapshot or catch-up, then waits for notify()
|
|
201
|
+
subscriber.notify(seq); // called by the plugin's /admin/identity/notify route
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Guarantees:
|
|
205
|
+
|
|
206
|
+
- `apply` receives changes in `seq` order, in batches; the cursor advances
|
|
207
|
+
**only after `apply` resolves**, in the same transaction when the cursor
|
|
208
|
+
store is the plugin's own database (SQLite helper provided).
|
|
209
|
+
- One pull at a time per subscriber; notifications during a pull coalesce.
|
|
210
|
+
- A changed `directoryId` triggers a full snapshot instead of a catch-up.
|
|
211
|
+
- Retries with backoff on 5xx and network errors; a 403 (plugin no longer
|
|
212
|
+
allowed to query) stops the subscriber and reports it.
|
|
213
|
+
|
|
214
|
+
### 2. `verifyIdentity` — who is calling, provably
|
|
215
|
+
|
|
216
|
+
Requests reach applications with a **signed identity assertion** instead of
|
|
217
|
+
bare `Remote-*` headers:
|
|
218
|
+
|
|
219
|
+
- **Through the app-plugin forwarder:** the server half adds the caller to
|
|
220
|
+
the caller assertion it already signs (app-plugin design, *Authentication
|
|
221
|
+
between the halves*).
|
|
222
|
+
- **Through a plain tunnel** (`authenticated` / `restricted`): Gatekeeper,
|
|
223
|
+
already the `auth_request` target, returns
|
|
224
|
+
`X-Lamalibre-Identity: <JWT>` on allow; nginx copies it to the upstream
|
|
225
|
+
request (`auth_request_set` + `proxy_set_header`) and still blanks every
|
|
226
|
+
client-supplied `Remote-*` and `X-Lamalibre-Identity` header first.
|
|
227
|
+
The JWT carries `sub` = `userId`, `username`, the declared attributes, the
|
|
228
|
+
plugin's roles, `aud` = the tunnel's plugin or FQDN, and a 60 s lifetime.
|
|
229
|
+
It is signed with a key Lamaste generates; applications verify it against
|
|
230
|
+
Lamaste's published key set.
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
fastify.register(identityPlugin, { audience: 'pdks', keys: lamasteJwks });
|
|
234
|
+
// request.identity: { userId, username, displayName?, email?, roles } | null
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Header parsing (`parseIdentity`) stays for compatibility but is documented as
|
|
238
|
+
**not** a trust mechanism.
|
|
239
|
+
|
|
240
|
+
### 3. Projection helpers
|
|
241
|
+
|
|
242
|
+
A small SQLite schema and helpers for the common case — a `lamaste_users`
|
|
243
|
+
table (`user_id PRIMARY KEY`, `username`, `display_name`, `email`, `roles`,
|
|
244
|
+
`status`, `version`, `removed_at`) plus the cursor row — so plugins that just
|
|
245
|
+
need names and roles do not each write their own. Plugins with their own
|
|
246
|
+
person model (PDKS) implement `apply` against their tables instead.
|
|
247
|
+
|
|
248
|
+
## Removal and history
|
|
249
|
+
|
|
250
|
+
Removing a user's entitlement or the user does **not** delete history in
|
|
251
|
+
applications:
|
|
252
|
+
|
|
253
|
+
- `remove` marks the local row `removed_at` and stops the person from using
|
|
254
|
+
the application (their requests no longer carry an entitlement anyway).
|
|
255
|
+
- Historical rows keep pointing at the `userId`; the local row keeps the last
|
|
256
|
+
known name so reports still read "Ayşe Yılmaz", not a UUID.
|
|
257
|
+
- Erasure (a person's right to have data deleted) is a separate, explicit
|
|
258
|
+
operation: Lamaste emits `remove` with `reason: 'erased'`, and the
|
|
259
|
+
application must anonymise its rows for that `userId`. Each plugin
|
|
260
|
+
documents what erasure does to its data.
|
|
261
|
+
|
|
262
|
+
## Employees, badges and offboarding
|
|
263
|
+
|
|
264
|
+
Decided 2026-10-13: **employees are Lamaste users.** There is one directory
|
|
265
|
+
for everyone who works on the site; nobody exists only inside an application.
|
|
266
|
+
Ending a person's access to everything — signing in, GitLab, printing at the
|
|
267
|
+
portal, doors — is **one action in Lamaste**: disable the user (or remove an
|
|
268
|
+
entitlement to end one thing).
|
|
269
|
+
|
|
270
|
+
### Credentials a user holds
|
|
271
|
+
|
|
272
|
+
A user can hold several **credentials**, each of which proves "this is that
|
|
273
|
+
user" in a different place:
|
|
274
|
+
|
|
275
|
+
| Credential | Where it is presented | Verified by |
|
|
276
|
+
|---|---|---|
|
|
277
|
+
| Password + TOTP | Browser (Authelia) | Authelia |
|
|
278
|
+
| Badges (card ids) | Card readers at kiosks, the portal, doors | The site agent, from its local projection |
|
|
279
|
+
| Registry / API tokens | Terminals, machines | The service that issued them (e.g. Feria) |
|
|
280
|
+
|
|
281
|
+
Badges are managed in Lamaste on the user's page: enrol (scan or type the
|
|
282
|
+
id), label (e.g. "13.56 MHz", "125 kHz"), revoke. A person may hold several —
|
|
283
|
+
the taped 13.56 MHz + 125 kHz pair PDKS already handles is two badges on one
|
|
284
|
+
user. Badge ids are a user attribute plugins can declare (`"badges"`), so a
|
|
285
|
+
card-reading plugin receives them in its audience and nothing else does.
|
|
286
|
+
|
|
287
|
+
Disabling a user disables every credential at once. Revoking one badge (lost
|
|
288
|
+
card) leaves the others working.
|
|
289
|
+
|
|
290
|
+
### Actions authorised by a badge
|
|
291
|
+
|
|
292
|
+
A card scan becomes an authenticated, authorised request:
|
|
293
|
+
|
|
294
|
+
```
|
|
295
|
+
card read ─► site agent: badge id → userId (local projection)
|
|
296
|
+
─► is the user active? does the user hold the role for this action?
|
|
297
|
+
(e.g. pdks-portal: "print", doors: "door:main-gate")
|
|
298
|
+
─► allow / deny, locally
|
|
299
|
+
─► audit event { userId, badgeId, action, result, at } — kept locally,
|
|
300
|
+
forwarded to Lamaste's audit log when online
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
- **Authorisation is a role on an entitlement**, exactly like the web: the
|
|
304
|
+
portal plugin declares `print`, a door plugin declares one role per door or
|
|
305
|
+
door group. Granting "can print" is a Gatekeeper grant; so is revoking it.
|
|
306
|
+
- **The decision is made on site**, from the agent's projection, so it works
|
|
307
|
+
when the site's internet line is down (it has gone down before — see
|
|
308
|
+
`herd/docs/site-network-architecture.md`).
|
|
309
|
+
- **Every action is logged with the user**, not the card: "printed 3 labels —
|
|
310
|
+
Ayşe Yılmaz (badge 0006552424)". The badge id stays on the event because
|
|
311
|
+
when a card misbehaves, the operator needs to know which one was presented.
|
|
312
|
+
|
|
313
|
+
### Revocation latency and offline sites
|
|
314
|
+
|
|
315
|
+
"One action ends everything" is only true once the change reaches each
|
|
316
|
+
place that enforces it:
|
|
317
|
+
|
|
318
|
+
| Enforcer | Reached by | Typical latency |
|
|
319
|
+
|---|---|---|
|
|
320
|
+
| Authelia (web sign-in) | Lamaste writes it directly | immediate |
|
|
321
|
+
| Tunnelled web apps | Gatekeeper, per request | immediate (within its cache lifetime) |
|
|
322
|
+
| Agent-hosted app plugins | Notify through the internal tunnel | seconds |
|
|
323
|
+
| Site agents (badges, doors) | Notify, else next 30 s sync | seconds; **unbounded while the site is offline** |
|
|
324
|
+
| GitLab | Connector (below) | seconds |
|
|
325
|
+
|
|
326
|
+
Offline sites are the hard case. A badge decision taken from a projection
|
|
327
|
+
that has not heard about a revocation will allow a dismissed employee in.
|
|
328
|
+
Each badge-reading plugin therefore declares an **offline policy**:
|
|
329
|
+
|
|
330
|
+
- `allow-cached` — keep deciding from the projection (printing, attendance:
|
|
331
|
+
low harm, and refusing would block the whole site).
|
|
332
|
+
- `fail-closed-after: <duration>` — deny once the projection has not
|
|
333
|
+
synchronised for longer than the duration (doors).
|
|
334
|
+
|
|
335
|
+
The agent reports projection age to the panel, so an offline site is visible.
|
|
336
|
+
|
|
337
|
+
### Card security
|
|
338
|
+
|
|
339
|
+
Badge ids are identifiers, not secrets. The 125 kHz EM cards in use are
|
|
340
|
+
trivially cloneable, and plain 13.56 MHz UID reads are copyable too. That is
|
|
341
|
+
acceptable for attendance and printing, where a cloned card is logged under
|
|
342
|
+
a real person. **It is not acceptable for doors.** Doors need cards that prove
|
|
343
|
+
possession cryptographically (e.g. MIFARE DESFire EV2/EV3 with keys held by
|
|
344
|
+
the reader), which is a reader and card change, not a software one. Until
|
|
345
|
+
then a door plugin should treat a badge as one factor and accept it only for
|
|
346
|
+
low-risk doors.
|
|
347
|
+
|
|
348
|
+
## Connectors: GitLab
|
|
349
|
+
|
|
350
|
+
GitLab is the first **connector** — a subscriber that projects the directory
|
|
351
|
+
into a system that has its own user database:
|
|
352
|
+
|
|
353
|
+
- **Sign-in:** GitLab signs users in through Lamaste's Authelia as an OpenID
|
|
354
|
+
Connect provider (GitLab's OmniAuth `openid_connect`), the same OIDC
|
|
355
|
+
provider the Feria design needs. Password sign-in on GitLab is turned off,
|
|
356
|
+
so a GitLab account cannot outlive the Lamaste user.
|
|
357
|
+
- **Provisioning:** an `IdentitySubscriber` for the `gitlab` plugin's
|
|
358
|
+
audience uses GitLab's admin API to create users (on first entitlement),
|
|
359
|
+
update name and email, **block** on `remove` (blocking keeps their issues,
|
|
360
|
+
comments and history intact), and unblock on re-entitlement. GitLab users
|
|
361
|
+
are matched to Lamaste by `userId`, stored in GitLab as the OIDC identity's
|
|
362
|
+
`extern_uid`.
|
|
363
|
+
- **Acting for a user from other applications:** a portal at a kiosk that
|
|
364
|
+
lets a badged-in employee comment on or close an issue calls GitLab's API
|
|
365
|
+
**as that user**, with an admin token and GitLab's `Sudo` header naming the
|
|
366
|
+
user. That token is the most powerful credential in the setup: it lives
|
|
367
|
+
only in the connector (an agent-hosted plugin), never on a kiosk; kiosks ask
|
|
368
|
+
the connector, which checks the caller's identity assertion and the
|
|
369
|
+
employee's entitlement before acting.
|
|
370
|
+
|
|
371
|
+
The same shape fits later systems: a door controller, a printer server, an
|
|
372
|
+
HR system — each is a subscriber with its own projection and its own idea of
|
|
373
|
+
"block".
|
|
374
|
+
|
|
375
|
+
**Feria** is one too. Its design (`feria/docs/design/networked-registry-and-login.md`)
|
|
376
|
+
asks Lamaste for a *group-change hook* that revokes or downgrades a person's
|
|
377
|
+
registry tokens when their `feria-*` membership changes. In this model that
|
|
378
|
+
hook is Feria's identity subscription: Feria declares the roles `reader`,
|
|
379
|
+
`publisher` and `admin`, entitlements grant them, and a `remove` or a role
|
|
380
|
+
change in Feria's audience triggers the revocation. One mechanism, not a
|
|
381
|
+
special hook per plugin.
|
|
382
|
+
|
|
383
|
+
## External systems of record
|
|
384
|
+
|
|
385
|
+
PDKS's roster is currently pushed by samplepark.tr's personnel system
|
|
386
|
+
(`HERD_PDKS_SYNC`, `PUT /kiosk/pdks/roster`). With a Lamaste directory, the
|
|
387
|
+
arrows have to be decided once:
|
|
388
|
+
|
|
389
|
+
- **Lamaste is the source:** samplepark pushes people into Lamaste's
|
|
390
|
+
directory (a directory import API with the same full-snapshot semantics),
|
|
391
|
+
and PDKS follows Lamaste.
|
|
392
|
+
- **The external system stays the source** and Lamaste mirrors it — same
|
|
393
|
+
import API, run by a connector.
|
|
394
|
+
|
|
395
|
+
Either way PDKS stops being fed directly; there is one path into the
|
|
396
|
+
directory and one out of it.
|
|
397
|
+
|
|
398
|
+
## Security
|
|
399
|
+
|
|
400
|
+
- Directory queries require the plugin's own certificate and the
|
|
401
|
+
`identity:query` capability **scoped to that plugin**: a plugin can read its
|
|
402
|
+
audience, never the whole directory.
|
|
403
|
+
- A plugin receives only the attributes it declared, and the admin approves
|
|
404
|
+
them at install.
|
|
405
|
+
- Identity assertions are signed (Ed25519), short-lived, audience-bound; the
|
|
406
|
+
signing key is 0600, owned by the Lamaste service account; rotation keeps
|
|
407
|
+
the previous key in the published set for one assertion lifetime.
|
|
408
|
+
- Client-supplied `Remote-*` and `X-Lamalibre-Identity` headers are always
|
|
409
|
+
removed at the relay before any value is set.
|
|
410
|
+
- The change feed never carries credentials — no passwords, no TOTP data, no
|
|
411
|
+
tokens.
|
|
412
|
+
|
|
413
|
+
## Lamaste changes required
|
|
414
|
+
|
|
415
|
+
| # | Change |
|
|
416
|
+
|---|---|
|
|
417
|
+
| 1 | Directory store in SQLite with `userId`, versions, `seq` triggers, `directoryId`; `users.yml` becomes derived. Migration assigns ids to existing users. |
|
|
418
|
+
| 2 | One entitlement model: migrate `user-plugin-access.json` into Gatekeeper grants with roles. |
|
|
419
|
+
| 3 | `identity` block in the plugin manifest (roles, attributes). |
|
|
420
|
+
| 4 | Audience snapshot and change-feed endpoints, scoped per plugin. |
|
|
421
|
+
| 5 | Notification fan-out: app-plugin forwarder, in-process event, `seq` in agent sync responses. |
|
|
422
|
+
| 6 | Identity assertions: signing key, JWKS endpoint, Gatekeeper returns the assertion, nginx forwards it. |
|
|
423
|
+
| 7 | Bulk user import (CSV) and a directory import API for external systems of record. |
|
|
424
|
+
| 8 | SDK: `IdentitySubscriber`, `verifyIdentity` plugin, SQLite projection helpers. |
|
|
425
|
+
| 9 | Badges as user credentials: enrol, label, revoke; `badges` attribute for card-reading plugins. |
|
|
426
|
+
| 10 | Audit log: events forwarded by plugins and agents (`userId`, credential, action, result). |
|
|
427
|
+
| 11 | Projection age reported by agents; offline policy declared by badge-reading plugins. |
|
|
428
|
+
| 12 | Authelia OIDC provider (shared with Feria) and a GitLab connector plugin. |
|
|
429
|
+
|
|
430
|
+
## Open questions
|
|
431
|
+
|
|
432
|
+
1. *(Decided 2026-10-13: employees are Lamaste users; badges are credentials on the user.)*
|
|
433
|
+
2. **PDKS's source of truth:** samplepark pushes into Lamaste, or Lamaste
|
|
434
|
+
mirrors samplepark?
|
|
435
|
+
3. **Erasure:** which plugins hold data that must be anonymised on request,
|
|
436
|
+
and is a single `erased` event enough for them?
|
|
437
|
+
4. **Door hardware:** which doors, and are their readers able to use
|
|
438
|
+
cryptographic cards (DESFire)? Until then, doors stay out of scope.
|
|
439
|
+
5. **GitLab password sign-in:** turn it off once OIDC sign-in works (keeps
|
|
440
|
+
offboarding single-action), with the root account as the only exception?
|
|
441
|
+
6. **Signing:** Gatekeeper signs assertions (it is already on the request
|
|
442
|
+
path), or the panel issues them and Gatekeeper only forwards — which keeps
|
|
443
|
+
Gatekeeper free of keys, as its design intends?
|
package/vendor/docs/package.json
CHANGED
|
@@ -29,7 +29,7 @@ feria dev registry — OUT OF SCOPE, do not touch
|
|
|
29
29
|
|
|
30
30
|
### Naming Rules
|
|
31
31
|
|
|
32
|
-
- **`lamaste`** is the core lib. You `import { ... } from '@lamalibre/lamaste@4.
|
|
32
|
+
- **`lamaste`** is the core lib. You `import { ... } from '@lamalibre/lamaste@4.2.0'`. No `-lib` suffix.
|
|
33
33
|
- **`-d` suffix** for daemons — Unix convention (`httpd`, `sshd`, `mongod`). Instantly signals "long-running process".
|
|
34
34
|
- **`-ui`** for React component libraries — universal term, avoids "serverd serves the panel" tautology.
|
|
35
35
|
- **`create-*`** for all npx one-shot tools — the one npm convention everyone knows.
|
|
@@ -74,24 +74,24 @@ lamaste core library (types, constants, schemas, pl
|
|
|
74
74
|
|
|
75
75
|
| # | Package (npm name) | Type | Depends on | Purpose |
|
|
76
76
|
| --- | ----------------------------------- | ----------- | ---------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
77
|
-
| 1 | `@lamalibre/lamaste@4.
|
|
78
|
-
| | | | | Subpath: `@lamalibre/lamaste@4.
|
|
79
|
-
| | | | | Subpath: `@lamalibre/lamaste@4.
|
|
80
|
-
| 2 | `@lamalibre/lamaste-agent@4.
|
|
81
|
-
| 3 | `@lamalibre/lamaste-agentd@4.
|
|
82
|
-
| 4 | `@lamalibre/lamaste-agent-ui@4.
|
|
83
|
-
| 5 | `@lamalibre/lamaste-server@4.
|
|
84
|
-
| 6 | `@lamalibre/lamaste-serverd@4.
|
|
85
|
-
| 7 | `@lamalibre/lamaste-server-ui@4.
|
|
86
|
-
| 8 | `@lamalibre/lamaste@4.
|
|
87
|
-
| 9 | `@lamalibre/lamaste-cloud@4.
|
|
88
|
-
| 10 | `@lamalibre/lamaste-tickets@4.
|
|
89
|
-
| 11 | `@lamalibre/lamaste-identity@4.
|
|
90
|
-
| 12 | `@lamalibre/create-lamaste@4.
|
|
91
|
-
| 13 | `@lamalibre/create-lamaste-agent@4.
|
|
92
|
-
| 14 | `@lamalibre/create-lamaste-desktop@4.
|
|
93
|
-
| 15 | `@lamalibre/create-lamaste-admin@4.
|
|
94
|
-
| 16 | `@lamalibre/create-lamaste-e2e@4.
|
|
77
|
+
| 1 | `@lamalibre/lamaste@4.2.0` | core lib | nothing | Types, constants, Zod schemas, plugin host Fastify plugin, atomic file helpers, client interfaces |
|
|
78
|
+
| | | | | Subpath: `@lamalibre/lamaste@4.2.0/agent` — agent domain logic |
|
|
79
|
+
| | | | | Subpath: `@lamalibre/lamaste@4.2.0/server` — server domain logic |
|
|
80
|
+
| 2 | `@lamalibre/lamaste-agent@4.2.0` | CLI | `lamaste` | Thin agent CLI. No Fastify, no HTTP |
|
|
81
|
+
| 3 | `@lamalibre/lamaste-agentd@4.2.0` | daemon | `lamaste` | Agent REST API on :9393. No CLI argument parsing |
|
|
82
|
+
| 4 | `@lamalibre/lamaste-agent-ui@4.2.0` | UI lib | `lamaste` | Agent React components + browser SPA build. Used by desktop and agentd |
|
|
83
|
+
| 5 | `@lamalibre/lamaste-server@4.2.0` | CLI | `lamaste` | **NEW.** Thin server CLI. No Fastify, no HTTP |
|
|
84
|
+
| 6 | `@lamalibre/lamaste-serverd@4.2.0` | daemon | `lamaste` | Server REST API on :3100. No CLI argument parsing. Renamed from `serverd` |
|
|
85
|
+
| 7 | `@lamalibre/lamaste-server-ui@4.2.0` | UI lib | `lamaste` | Server/admin React components + browser SPA build. Replaces both `panel-client` and `lamaste-admin-panel` |
|
|
86
|
+
| 8 | `@lamalibre/lamaste@4.2.0-desktop` | desktop | `lamaste` | Unified Tauri app. Multi-target: full, agent-only, server-only. Credentials, tray, deep links |
|
|
87
|
+
| 9 | `@lamalibre/lamaste-cloud@4.2.0` | lib + CLI | — | Cloud provider abstraction (already correct pattern) |
|
|
88
|
+
| 10 | `@lamalibre/lamaste-tickets@4.2.0` | SDK | — | Agent-to-agent authorization |
|
|
89
|
+
| 11 | `@lamalibre/lamaste-identity@4.2.0` | SDK | — | Authelia identity parsing and query |
|
|
90
|
+
| 12 | `@lamalibre/create-lamaste@4.2.0` | provisioner | `lamaste` | Server provisioner: OS hardening, nginx, Authelia, certs. `npx create-lamaste` |
|
|
91
|
+
| 13 | `@lamalibre/create-lamaste-agent@4.2.0` | provisioner | `lamaste` | Agent provisioner: cert enrollment, service setup. `npx create-lamaste-agent`. Renamed from `install-lamaste-agent` |
|
|
92
|
+
| 14 | `@lamalibre/create-lamaste-desktop@4.2.0` | installer | — | Desktop app installer. `npx create-lamaste-desktop`. Renamed from `install-lamaste-desktop` |
|
|
93
|
+
| 15 | `@lamalibre/create-lamaste-admin@4.2.0` | provisioner | `lamaste` | Admin cert upgrade to hardware-bound. `npx create-lamaste-admin`. Renamed from `install-lamaste-admin` |
|
|
94
|
+
| 16 | `@lamalibre/create-lamaste-e2e@4.2.0` | test infra | — | E2E test MCP server. `npx create-lamaste-e2e`. Renamed from `install-lamaste-e2e-mcp` |
|
|
95
95
|
| 17 | `@lamalibre/feria` | dev tool | — | Dev registry + release runner. **Out of scope — do not touch in this refactor** |
|
|
96
96
|
|
|
97
97
|
### Rename Map (1.x → 2.0.0)
|
|
@@ -116,7 +116,7 @@ The core library uses **subpath exports** to provide domain-specific entry point
|
|
|
116
116
|
|
|
117
117
|
```json
|
|
118
118
|
{
|
|
119
|
-
"name": "@lamalibre/lamaste@4.
|
|
119
|
+
"name": "@lamalibre/lamaste@4.2.0",
|
|
120
120
|
"exports": {
|
|
121
121
|
".": "./dist/index.js",
|
|
122
122
|
"./agent": "./dist/agent/index.js",
|
|
@@ -126,9 +126,9 @@ The core library uses **subpath exports** to provide domain-specific entry point
|
|
|
126
126
|
```
|
|
127
127
|
|
|
128
128
|
```typescript
|
|
129
|
-
import { RESERVED_API_PREFIXES, pluginManifestSchema } from '@lamalibre/lamaste@4.
|
|
130
|
-
import { loadRegistry, loadAgent, isAgentLoaded } from '@lamalibre/lamaste@4.
|
|
131
|
-
import { createTunnel, rotateCert } from '@lamalibre/lamaste@4.
|
|
129
|
+
import { RESERVED_API_PREFIXES, pluginManifestSchema } from '@lamalibre/lamaste@4.2.0';
|
|
130
|
+
import { loadRegistry, loadAgent, isAgentLoaded } from '@lamalibre/lamaste@4.2.0/agent';
|
|
131
|
+
import { createTunnel, rotateCert } from '@lamalibre/lamaste@4.2.0/server';
|
|
132
132
|
```
|
|
133
133
|
|
|
134
134
|
**Why subpath exports instead of three packages (`lamaste-lib`, `lamaste-lib-agent`, `lamaste-lib-server`):**
|
|
@@ -149,7 +149,7 @@ import { createTunnel, rotateCert } from '@lamalibre/lamaste@4.0.0/server';
|
|
|
149
149
|
|
|
150
150
|
| Layer | Agent side | Server side |
|
|
151
151
|
| ------------------------ | ----------------------------------------------- | ---------------------------------------------- |
|
|
152
|
-
| **Domain logic** | `@lamalibre/lamaste@4.
|
|
152
|
+
| **Domain logic** | `@lamalibre/lamaste@4.2.0/agent` | `@lamalibre/lamaste@4.2.0/server` |
|
|
153
153
|
| **Operational CLI** | `lamaste-agent` | `lamaste-server` (new) |
|
|
154
154
|
| **Long-running daemon** | `lamalibre-lamaste-agentd` on :9393 (extracted) | `lamalibre-lamaste-serverd` on :3100 (renamed) |
|
|
155
155
|
| **UI components** | `lamaste-agent-ui` (renamed) | `lamaste-server-ui` (merged) |
|
|
@@ -471,7 +471,7 @@ The Rust backend has both `commands.rs` (legacy) and `agents.rs` (multi-agent).
|
|
|
471
471
|
|
|
472
472
|
Create `packages/lamaste/` (TypeScript) with subpath exports:
|
|
473
473
|
|
|
474
|
-
**Root (`@lamalibre/lamaste@4.
|
|
474
|
+
**Root (`@lamalibre/lamaste@4.2.0`):**
|
|
475
475
|
|
|
476
476
|
- Types: `AdminClient`, `AgentClient` TypeScript interfaces, plugin manifest types, capability types
|
|
477
477
|
- Constants: Reserved API prefixes, curated plugin list, capability names
|
|
@@ -479,7 +479,7 @@ Create `packages/lamaste/` (TypeScript) with subpath exports:
|
|
|
479
479
|
- Plugin Host: Fastify plugin for plugin route mounting, bundle serving, disabled handler (unified from three routers)
|
|
480
480
|
- File Helpers: Atomic write (temp → fsync → rename), promise-chain mutex
|
|
481
481
|
|
|
482
|
-
**Agent subpath (`@lamalibre/lamaste@4.
|
|
482
|
+
**Agent subpath (`@lamalibre/lamaste@4.2.0/agent`):**
|
|
483
483
|
|
|
484
484
|
- Platform: Path helpers, platform detection (from `platform.js`)
|
|
485
485
|
- Registry: Agent registry CRUD, label validation, legacy migration (from `registry.js`)
|
|
@@ -491,7 +491,7 @@ Create `packages/lamaste/` (TypeScript) with subpath exports:
|
|
|
491
491
|
- Server Registry: servers.json and storage-servers.json CRUD (new — from Rust `cloud.rs`)
|
|
492
492
|
- Mode: Server mode and admin cert file operations (new — from Rust `mode.rs`)
|
|
493
493
|
|
|
494
|
-
**Server subpath (`@lamalibre/lamaste@4.
|
|
494
|
+
**Server subpath (`@lamalibre/lamaste@4.2.0/server`):**
|
|
495
495
|
|
|
496
496
|
- Plugins: Server-side plugin lifecycle (uses shared schema from root)
|
|
497
497
|
- Tunnels: Tunnel creation workflow with rollback (from `routes/tunnels.js`)
|
|
@@ -58,12 +58,13 @@ ${b('COMMANDS')}
|
|
|
58
58
|
${c('status')} Server health, uptime, services, certificate status
|
|
59
59
|
${c('logs')} View server log output (journalctl wrapper)
|
|
60
60
|
${c('restart')} Restart panel server and optionally gatekeeper
|
|
61
|
-
${c('plugins')} Manage server-side plugins (list, install, enable, disable, uninstall)
|
|
61
|
+
${c('plugins')} Manage server-side plugins (list, install, enable, disable, uninstall, oidc-clients, rotate-secret)
|
|
62
62
|
${c('tunnels')} Manage tunnels (list, create, delete, toggle)
|
|
63
63
|
${c('sites')} Manage static sites (list, create, delete)
|
|
64
64
|
${c('certs')} Certificate status, renewal, and agent cert listing
|
|
65
65
|
${c('reset-admin')} Emergency admin reset (reverts to P12, clears 2FA)
|
|
66
66
|
${c('chisel')} Manage chisel tunnel-server credentials (rotate-credential)
|
|
67
|
+
${c('registry-credential')} Registry (Feria) service credential used to broker agent install tokens
|
|
67
68
|
${c('uninstall')} Full server teardown (requires confirmation)
|
|
68
69
|
|
|
69
70
|
${b('GLOBAL FLAGS')}
|
|
@@ -93,6 +94,9 @@ ${b('EXAMPLES')}
|
|
|
93
94
|
${d('# Install a plugin')}
|
|
94
95
|
${c('lamaste-server plugins install @lamalibre/herd-server')}
|
|
95
96
|
|
|
97
|
+
${d("# Broker agent install tokens from the registry as the panel's own Authelia client")}
|
|
98
|
+
${c('lamaste-server registry-credential set --url https://feria.example.com --oidc')}
|
|
99
|
+
|
|
96
100
|
${d('# List tunnels')}
|
|
97
101
|
${c('lamaste-server tunnels list')}
|
|
98
102
|
|
|
@@ -174,6 +178,11 @@ async function main() {
|
|
|
174
178
|
await runResetAdmin({ json, passwordFile });
|
|
175
179
|
break;
|
|
176
180
|
}
|
|
181
|
+
case 'registry-credential': {
|
|
182
|
+
const { runRegistryCredential } = await import('../src/commands/registry-credential.js');
|
|
183
|
+
await runRegistryCredential(args.slice(1), { json });
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
177
186
|
case 'chisel': {
|
|
178
187
|
const { runChisel } = await import('../src/commands/chisel.js');
|
|
179
188
|
await runChisel(args.slice(1), { json });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lamalibre/lamaste-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Server operations CLI for Lamaste",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
"src"
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
|
-
"lint": "echo 'No lint configured'"
|
|
16
|
+
"lint": "echo 'No lint configured'",
|
|
17
|
+
"test": "node --test test/*.test.js"
|
|
17
18
|
},
|
|
18
19
|
"dependencies": {
|
|
19
|
-
"@lamalibre/lamaste": "^4.
|
|
20
|
+
"@lamalibre/lamaste": "^4.2.0",
|
|
20
21
|
"chalk": "^5.3.0",
|
|
21
22
|
"execa": "^9.0.0"
|
|
22
23
|
},
|