@bevel-software/platform-core-backend 0.10.0 → 0.11.1
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/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +12 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +22 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +34 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +54 -28
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +129 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +435 -66
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts.map +1 -1
- package/dist/modules/access/access-declarations.js +5 -3
- package/dist/modules/access/access-declarations.js.map +1 -1
- package/dist/modules/access/access-mutation.service.d.ts +39 -6
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +78 -18
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +31 -4
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -16
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +204 -82
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts +134 -0
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
- package/dist/modules/access/admin-locked-commit.js +277 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -0
- package/dist/modules/access/admin-route-helpers.d.ts +32 -0
- package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
- package/dist/modules/access/admin-route-helpers.js +44 -0
- package/dist/modules/access/admin-route-helpers.js.map +1 -0
- package/dist/modules/access/capability-registry.d.ts +41 -0
- package/dist/modules/access/capability-registry.d.ts.map +1 -0
- package/dist/modules/access/capability-registry.js +46 -0
- package/dist/modules/access/capability-registry.js.map +1 -0
- package/dist/modules/access/directory-sync-bot.d.ts +13 -0
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
- package/dist/modules/access/directory-sync-bot.js +64 -0
- package/dist/modules/access/directory-sync-bot.js.map +1 -0
- package/dist/modules/access/group-files.d.ts +83 -0
- package/dist/modules/access/group-files.d.ts.map +1 -0
- package/dist/modules/access/group-files.js +167 -0
- package/dist/modules/access/group-files.js.map +1 -0
- package/dist/modules/access/groups-admin.routes.d.ts +19 -0
- package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.routes.js +98 -0
- package/dist/modules/access/groups-admin.routes.js.map +1 -0
- package/dist/modules/access/groups-admin.service.d.ts +166 -0
- package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.service.js +442 -0
- package/dist/modules/access/groups-admin.service.js.map +1 -0
- package/dist/modules/access/groups-edit.d.ts +58 -0
- package/dist/modules/access/groups-edit.d.ts.map +1 -0
- package/dist/modules/access/groups-edit.js +162 -0
- package/dist/modules/access/groups-edit.js.map +1 -0
- package/dist/modules/access/reference-scan.d.ts +141 -0
- package/dist/modules/access/reference-scan.d.ts.map +1 -0
- package/dist/modules/access/reference-scan.js +440 -0
- package/dist/modules/access/reference-scan.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +88 -119
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +230 -384
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/roles-edit.d.ts +51 -25
- package/dist/modules/access/roles-edit.d.ts.map +1 -1
- package/dist/modules/access/roles-edit.js +133 -59
- package/dist/modules/access/roles-edit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.d.ts +28 -0
- package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-committer.js +139 -0
- package/dist/modules/access/synced-groups-committer.js.map +1 -0
- package/dist/modules/access/synced-groups-writer.d.ts +78 -0
- package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-writer.js +219 -0
- package/dist/modules/access/synced-groups-writer.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +17 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +9 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +9 -3
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +126 -2
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +14 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +6 -1
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +11 -1
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +15 -1
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +34 -11
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +165 -37
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
- package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/workflow/locking-filesystem.js +181 -28
- package/dist/modules/workflow/locking-filesystem.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +19 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +17 -2
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +113 -19
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/kb-template/AGENTS.md +4 -1
- package/migrations/0004_file_lock_mode.sql +2 -0
- package/migrations/meta/0004_snapshot.json +1564 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/core/create-core-server.ts +13 -0
- package/src/core/create-core-services.ts +68 -0
- package/src/index.ts +10 -0
- package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
- package/src/modules/access/__tests__/access-groups.test.ts +427 -0
- package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
- package/src/modules/access/__tests__/access-splice.test.ts +65 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
- package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
- package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
- package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
- package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
- package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
- package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
- package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
- package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
- package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
- package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
- package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
- package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
- package/src/modules/access/access-control.interface.ts +66 -32
- package/src/modules/access/access-control.service.ts +536 -73
- package/src/modules/access/access-declarations.ts +5 -2
- package/src/modules/access/access-mutation.service.ts +88 -14
- package/src/modules/access/access-splice.ts +55 -17
- package/src/modules/access/access.routes.ts +227 -93
- package/src/modules/access/admin-locked-commit.ts +331 -0
- package/src/modules/access/admin-route-helpers.ts +55 -0
- package/src/modules/access/capability-registry.ts +74 -0
- package/src/modules/access/directory-sync-bot.ts +76 -0
- package/src/modules/access/group-files.ts +212 -0
- package/src/modules/access/groups-admin.routes.ts +113 -0
- package/src/modules/access/groups-admin.service.ts +551 -0
- package/src/modules/access/groups-edit.ts +187 -0
- package/src/modules/access/reference-scan.ts +513 -0
- package/src/modules/access/roles-admin.service.ts +290 -418
- package/src/modules/access/roles-edit.ts +134 -61
- package/src/modules/access/synced-groups-committer.ts +177 -0
- package/src/modules/access/synced-groups-writer.ts +303 -0
- package/src/modules/database/core-schema.ts +9 -0
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
- package/src/modules/mcp/mcp.routes.ts +137 -2
- package/src/modules/mcp/mcp.service.ts +6 -1
- package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
- package/src/modules/workflow/file-lock.service.ts +15 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
- package/src/modules/workflow/git/git.service.ts +174 -35
- package/src/modules/workflow/locking-filesystem.ts +188 -26
- package/src/modules/workflow/pending-commits.service.ts +27 -1
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
- package/src/modules/workflow/workflow.service.ts +140 -20
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EMAIL_REGEX,
|
|
3
|
+
GROUP_REF_PREFIX,
|
|
4
|
+
RESERVED_ROLE_NAMES,
|
|
5
|
+
ROLE_TOKEN_PREFIX,
|
|
6
|
+
canonicalEmail,
|
|
7
|
+
canonicalRoleName,
|
|
8
|
+
parseYamlSubset,
|
|
9
|
+
} from './access-control.service.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Group files — the "who you are" half of the roles/groups split.
|
|
13
|
+
*
|
|
14
|
+
* Groups are people-sets referenced by access grants ("GTM Team can read the
|
|
15
|
+
* battlecards"). A deployment has exactly ONE group source at a time — a mode
|
|
16
|
+
* derived from which file exists, so it holds at any git ref:
|
|
17
|
+
*
|
|
18
|
+
* - `synced-groups.yaml` EXISTS → IdP mode. The file is MACHINE-OWNED,
|
|
19
|
+
* regenerated wholesale from the SCIM directory mirror; membership is
|
|
20
|
+
* managed in the IdP. `groups.yaml` is ignored entirely.
|
|
21
|
+
* - otherwise → manual mode. `groups.yaml` (UI-edited)
|
|
22
|
+
* is the source.
|
|
23
|
+
*
|
|
24
|
+
* Both files share one format, the group twin of roles.yaml:
|
|
25
|
+
*
|
|
26
|
+
* groups:
|
|
27
|
+
* Engineering:
|
|
28
|
+
* - ada@x.io
|
|
29
|
+
* - bo@x.io
|
|
30
|
+
* Empty Group: []
|
|
31
|
+
*
|
|
32
|
+
* Parsing is FORGIVING at the entry level by design — a malformed email or a
|
|
33
|
+
* reserved/duplicate name skips that entry with a warning, it never takes the
|
|
34
|
+
* whole file down. Only structural failures (unparsable YAML, `groups:` not a
|
|
35
|
+
* mapping) reject the file, and even then the RESOLVER degrades to "this file
|
|
36
|
+
* contributes nothing" rather than throwing: unlike roles.yaml there is no
|
|
37
|
+
* admin-lockout risk here, so groups must never be able to brick access
|
|
38
|
+
* resolution. Strict acceptance for WRITES is the job of the write-side
|
|
39
|
+
* validator (`validateGroupsFile` below), which treats warnings as refusals.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export const GROUPS_YAML = 'groups.yaml';
|
|
43
|
+
export const SYNCED_GROUPS_YAML = 'synced-groups.yaml';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* THE name-safety predicate for principal display names — the single source
|
|
47
|
+
* of truth every surface derives from (group creation/rename, IdP sync
|
|
48
|
+
* materialization; thin assert wrappers sit on top). Returns a human-readable
|
|
49
|
+
* reason the name is unsafe, or null when it is fine.
|
|
50
|
+
*
|
|
51
|
+
* The character rules keep the emitted YAML parseable and the entry grammar
|
|
52
|
+
* unambiguous:
|
|
53
|
+
* `:` mis-tokenises as a nested mapping key
|
|
54
|
+
* `#` truncated as a comment by the subset parser's stripComment
|
|
55
|
+
* `<` / `>` collide with the `Name <email>` user-reference shape
|
|
56
|
+
* leading - tokenises as a list item
|
|
57
|
+
* \x00-\x1f control chars / newlines break line structure outright
|
|
58
|
+
* `role/…` reserved: that spelling is the EXPLICIT role token in access
|
|
59
|
+
* entries — a group carrying it could never be referenced.
|
|
60
|
+
*/
|
|
61
|
+
export function unsafeNameReason(displayName: string): string | null {
|
|
62
|
+
const trimmed = displayName.trim();
|
|
63
|
+
if (!trimmed) return 'empty name';
|
|
64
|
+
if (/[:#<>]/.test(trimmed)) return "contains ':', '#', '<', or '>'";
|
|
65
|
+
// eslint-disable-next-line no-control-regex
|
|
66
|
+
if (/[\x00-\x1f]/.test(trimmed)) return 'contains control characters';
|
|
67
|
+
if (trimmed.startsWith('-')) return "starts with '-'";
|
|
68
|
+
if (canonicalRoleName(trimmed).startsWith(ROLE_TOKEN_PREFIX)) {
|
|
69
|
+
return `starts with the reserved '${ROLE_TOKEN_PREFIX}' prefix (the explicit role token in access entries)`;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One parsed group: display name + canonicalised member emails. */
|
|
75
|
+
export interface GroupDefinition {
|
|
76
|
+
displayName: string;
|
|
77
|
+
emails: Set<string>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Canonical group name → definition, in file order. */
|
|
81
|
+
export type GroupsIndex = Map<string, GroupDefinition>;
|
|
82
|
+
|
|
83
|
+
export interface ParsedGroupsFile {
|
|
84
|
+
ok: true;
|
|
85
|
+
groups: GroupsIndex;
|
|
86
|
+
/** Entry-level problems that were skipped over (bad email, dup name, …). */
|
|
87
|
+
warnings: string[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface GroupsFileError {
|
|
91
|
+
ok: false;
|
|
92
|
+
errors: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse a group file (either of the two — same grammar). See the module note
|
|
97
|
+
* for the forgiving-vs-structural split.
|
|
98
|
+
*/
|
|
99
|
+
export function parseGroupsFile(
|
|
100
|
+
text: string,
|
|
101
|
+
filename: string,
|
|
102
|
+
): ParsedGroupsFile | GroupsFileError {
|
|
103
|
+
// Tolerate empty keys at parse: this file's contract is ENTRY-level
|
|
104
|
+
// forgiveness (a blank group name is skipped with a warning below), and a
|
|
105
|
+
// hard tokenizer error here would retire every OTHER group fail-closed.
|
|
106
|
+
const parsed = parseYamlSubset(text, { tolerateEmptyKeys: true });
|
|
107
|
+
if (!parsed.ok) return { ok: false, errors: [`${filename}: ${parsed.error}`] };
|
|
108
|
+
|
|
109
|
+
const root = parsed.value;
|
|
110
|
+
if (root == null || typeof root !== 'object' || Array.isArray(root)) {
|
|
111
|
+
return { ok: false, errors: [`${filename}: must be a top-level mapping`] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const groupsNode = (root as Record<string, unknown>).groups;
|
|
115
|
+
// An empty file (or one with no `groups:` key yet) is a valid empty set —
|
|
116
|
+
// a fresh deployment's groups.yaml starts this way.
|
|
117
|
+
if (groupsNode == null) return { ok: true, groups: new Map(), warnings: [] };
|
|
118
|
+
if (typeof groupsNode !== 'object' || Array.isArray(groupsNode)) {
|
|
119
|
+
return { ok: false, errors: [`${filename}: 'groups' must be a mapping`] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const groups: GroupsIndex = new Map();
|
|
123
|
+
const warnings: string[] = [];
|
|
124
|
+
|
|
125
|
+
for (const [displayName, value] of Object.entries(groupsNode as Record<string, unknown>)) {
|
|
126
|
+
const canonical = canonicalRoleName(displayName);
|
|
127
|
+
if (!canonical) {
|
|
128
|
+
warnings.push(`${filename}: empty group name — skipped`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// THE shared name-safety predicate (see `unsafeNameReason` above) — the
|
|
132
|
+
// same rules every write surface asserts. The parser must apply it too:
|
|
133
|
+
// a name the writers refuse (control chars, `<`/`>`, a leading `-`, the
|
|
134
|
+
// reserved `role/` prefix) can still reach a group file by hand edit or
|
|
135
|
+
// through a skewed writer, and accepting it here would let the resolver
|
|
136
|
+
// honor — and `validateGroupsFile` pass — a name no editor can ever
|
|
137
|
+
// produce or reference safely.
|
|
138
|
+
const unsafe = unsafeNameReason(displayName);
|
|
139
|
+
if (unsafe) {
|
|
140
|
+
warnings.push(
|
|
141
|
+
`${filename}: group ${JSON.stringify(displayName)} ${unsafe} — skipped`,
|
|
142
|
+
);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (RESERVED_ROLE_NAMES.has(canonical)) {
|
|
146
|
+
warnings.push(
|
|
147
|
+
`${filename}: group '${displayName}' uses reserved name '${canonical}' — skipped`,
|
|
148
|
+
);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (groups.has(canonical)) {
|
|
152
|
+
warnings.push(
|
|
153
|
+
`${filename}: group '${displayName}' canonicalises to '${canonical}', already declared as '${groups.get(canonical)!.displayName}' — skipped`,
|
|
154
|
+
);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (value !== null && !Array.isArray(value)) {
|
|
158
|
+
warnings.push(`${filename}: group '${displayName}' must be a list of emails — skipped`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const emails = new Set<string>();
|
|
162
|
+
for (const raw of value ?? []) {
|
|
163
|
+
if (typeof raw !== 'string') {
|
|
164
|
+
warnings.push(`${filename}: group '${displayName}' has a non-string entry — entry skipped`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const email = canonicalEmail(raw);
|
|
168
|
+
// The reserved `group:` prefix would PASS the email regex
|
|
169
|
+
// (`group:lee@x.io` shapes like an email) but it is the roles.yaml
|
|
170
|
+
// group-reference token, not an address — groups contain emails, never
|
|
171
|
+
// other groups. Skip it with its own warning so the write gate refuses
|
|
172
|
+
// it and the resolver never grants a colon-bearing "email".
|
|
173
|
+
if (email.startsWith(GROUP_REF_PREFIX)) {
|
|
174
|
+
warnings.push(
|
|
175
|
+
`${filename}: group '${displayName}' has a '${GROUP_REF_PREFIX}'-prefixed entry ${JSON.stringify(raw)} — groups contain emails, not group references; entry skipped`,
|
|
176
|
+
);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (!EMAIL_REGEX.test(email)) {
|
|
180
|
+
warnings.push(
|
|
181
|
+
`${filename}: group '${displayName}' has malformed email '${raw}' — entry skipped`,
|
|
182
|
+
);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
emails.add(email);
|
|
186
|
+
}
|
|
187
|
+
groups.set(canonical, { displayName: displayName.trim(), emails });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { ok: true, groups, warnings };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Write-gate twin of {@link parseGroupsFile}: strict — structural errors AND
|
|
195
|
+
* entry-level warnings both refuse the candidate, so a UI write can never land
|
|
196
|
+
* a group the resolver would silently skip.
|
|
197
|
+
*/
|
|
198
|
+
export function validateGroupsFile(
|
|
199
|
+
text: string,
|
|
200
|
+
filename: string,
|
|
201
|
+
): { ok: true } | { ok: false; errors: string[] } {
|
|
202
|
+
// Strict PARSE first (no empty-key tolerance): the read side forgives a
|
|
203
|
+
// blank key entry-level so other groups keep resolving, but a WRITE that
|
|
204
|
+
// contains one — anywhere, including outside the `groups:` mapping — must
|
|
205
|
+
// refuse rather than land bytes the tolerant reader silently drops.
|
|
206
|
+
const strict = parseYamlSubset(text.trim() ? text : 'groups:');
|
|
207
|
+
if (!strict.ok) return { ok: false, errors: [`${filename}: ${strict.error}`] };
|
|
208
|
+
const parsed = parseGroupsFile(text, filename);
|
|
209
|
+
if (!parsed.ok) return { ok: false, errors: parsed.errors };
|
|
210
|
+
if (parsed.warnings.length > 0) return { ok: false, errors: parsed.warnings };
|
|
211
|
+
return { ok: true };
|
|
212
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import type { AuthUser } from '@bevel-software/platform-shared';
|
|
3
|
+
import type { IAdminAccessService } from '../admin/admin.interface.js';
|
|
4
|
+
import { canonicalRoleName } from './access-control.service.js';
|
|
5
|
+
import { sendError as sharedSendError, requireNonEmptyString as sharedRequireNonEmptyString } from './admin-route-helpers.js';
|
|
6
|
+
import type { GroupsAdminService } from './groups-admin.service.js';
|
|
7
|
+
import '../auth/auth.middleware.js'; // Express Request augmentation
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Admin surface for GROUPS — the "who you are" half of the roles/groups
|
|
11
|
+
* split. Manual-mode CRUD on `groups.yaml`; in IdP mode the service refuses
|
|
12
|
+
* mutations with a typed 409 (`kind: 'idp-mode'`) and this router simply
|
|
13
|
+
* relays it. Admin-gated like the other admin routers: the roster is the org
|
|
14
|
+
* chart, not something every signed-in user may enumerate.
|
|
15
|
+
*/
|
|
16
|
+
export function createGroupsAdminRoutes(deps: {
|
|
17
|
+
groupsAdmin: GroupsAdminService;
|
|
18
|
+
adminAccess: IAdminAccessService;
|
|
19
|
+
/** Resolve the acting user for commit attribution. */
|
|
20
|
+
getUserById: (id: string) => Promise<AuthUser | null>;
|
|
21
|
+
}): express.Router {
|
|
22
|
+
const { groupsAdmin, adminAccess, getUserById } = deps;
|
|
23
|
+
const router = express.Router();
|
|
24
|
+
|
|
25
|
+
const requireAdmin: express.RequestHandler = async (req, res, next) => {
|
|
26
|
+
if (!(await adminAccess.isAdmin(req.userEmail))) {
|
|
27
|
+
res.status(403).json({ error: 'Admins only' });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
next();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const actorOf = async (req: express.Request): Promise<AuthUser> => {
|
|
34
|
+
const user = req.userId ? await getUserById(req.userId) : null;
|
|
35
|
+
if (user) return user;
|
|
36
|
+
// The JWT authenticated but the row is gone (erased mid-session) — the
|
|
37
|
+
// identity claims are still the honest attribution we hold.
|
|
38
|
+
return { id: req.userId ?? 'unknown', email: req.userEmail ?? 'unknown', name: req.userEmail ?? 'unknown' };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Shared access-family error shape + input coercion (admin-route-helpers):
|
|
42
|
+
// typed domain errors (incl. the roster's broken-groups 422) render
|
|
43
|
+
// themselves; anything else logs server-side and answers generically.
|
|
44
|
+
const sendError = (res: express.Response, err: unknown): void => sharedSendError(res, err, 'groups');
|
|
45
|
+
|
|
46
|
+
const requireNonEmptyString = (value: unknown, field: string): string =>
|
|
47
|
+
sharedRequireNonEmptyString(value, field, { trim: true });
|
|
48
|
+
|
|
49
|
+
// GET /api/admin/groups — mode + roster (groups with grant references and
|
|
50
|
+
// role assignments) + `groupsHealth` (the broken-source banner marker; a
|
|
51
|
+
// broken MANUAL groups.yaml instead answers 422 with the parse message so
|
|
52
|
+
// the operator sees what to fix).
|
|
53
|
+
router.get('/admin/groups', requireAdmin, async (_req, res) => {
|
|
54
|
+
try {
|
|
55
|
+
res.json(await groupsAdmin.getRoster());
|
|
56
|
+
} catch (err) {
|
|
57
|
+
sendError(res, err);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
router.post('/admin/groups', requireAdmin, async (req, res) => {
|
|
62
|
+
try {
|
|
63
|
+
const displayName = requireNonEmptyString((req.body ?? {}).displayName, 'displayName');
|
|
64
|
+
res.json(await groupsAdmin.createGroup(await actorOf(req), displayName));
|
|
65
|
+
} catch (err) {
|
|
66
|
+
sendError(res, err);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
router.delete('/admin/groups/:canonical', requireAdmin, async (req, res) => {
|
|
71
|
+
try {
|
|
72
|
+
const canonical = canonicalRoleName(String(req.params.canonical));
|
|
73
|
+
res.json(await groupsAdmin.deleteGroup(await actorOf(req), canonical));
|
|
74
|
+
} catch (err) {
|
|
75
|
+
sendError(res, err);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// PATCH /api/admin/groups/:canonical { newDisplayName } — rename;
|
|
80
|
+
// canonical-changing renames rewrite grant references atomically.
|
|
81
|
+
router.patch('/admin/groups/:canonical', requireAdmin, async (req, res) => {
|
|
82
|
+
try {
|
|
83
|
+
const canonical = canonicalRoleName(String(req.params.canonical));
|
|
84
|
+
const newDisplayName = requireNonEmptyString((req.body ?? {}).newDisplayName, 'newDisplayName');
|
|
85
|
+
res.json(await groupsAdmin.renameGroup(await actorOf(req), canonical, newDisplayName));
|
|
86
|
+
} catch (err) {
|
|
87
|
+
sendError(res, err);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
router.post('/admin/groups/:canonical/members', requireAdmin, async (req, res) => {
|
|
92
|
+
try {
|
|
93
|
+
const canonical = canonicalRoleName(String(req.params.canonical));
|
|
94
|
+
const email = requireNonEmptyString((req.body ?? {}).email, 'email');
|
|
95
|
+
res.json(await groupsAdmin.addMember(await actorOf(req), canonical, email));
|
|
96
|
+
} catch (err) {
|
|
97
|
+
sendError(res, err);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
router.delete('/admin/groups/:canonical/members/:email', requireAdmin, async (req, res) => {
|
|
102
|
+
try {
|
|
103
|
+
const canonical = canonicalRoleName(String(req.params.canonical));
|
|
104
|
+
res.json(
|
|
105
|
+
await groupsAdmin.removeMember(await actorOf(req), canonical, String(req.params.email)),
|
|
106
|
+
);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
sendError(res, err);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return router;
|
|
113
|
+
}
|