@hachej/boring-governance 0.1.71 → 0.1.72
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/README.md +54 -1
- package/dist/front/index.d.ts +1 -0
- package/dist/front/index.js +3 -1
- package/dist/scripts/governance-access-matrix.d.ts +1 -0
- package/dist/scripts/governance-access-matrix.js +350 -0
- package/dist/server/index.d.ts +14 -9
- package/dist/server/index.js +446 -97
- package/package.json +18 -11
package/README.md
CHANGED
|
@@ -30,4 +30,57 @@ const governanceCompanyAdmin = createGovernanceCompanyAdmin()
|
|
|
30
30
|
<CoreWorkspaceAgentFront companyAdmin={governanceCompanyAdmin} />
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Policy source is configured with `BORING_GOVERNANCE_POLICY_PATH`. Company context source roots use `BORING_GOVERNANCE_COMPANY_CONTEXT_ROOT` or the default workspace root resolver outside sandbox mode.
|
|
33
|
+
Policy source is configured with `BORING_GOVERNANCE_POLICY_PATH`. Company context source roots use `BORING_GOVERNANCE_COMPANY_CONTEXT_ROOT` or the default workspace root resolver outside sandbox mode. When an explicit, governance-owned company-context root is configured, verified tenant admins receive a tenant-wide read/write `company_context` binding; ordinary policy users receive only their regex-filtered readonly projection. Custom resolvers must opt into admin mutations with `allowAdminMutations: true` only when no other actor can mutate the root outside the governance store.
|
|
34
|
+
|
|
35
|
+
## Policy budgets
|
|
36
|
+
|
|
37
|
+
Model grants remain the model picker allowlist. Optional user budgets cap aggregate monthly spend across all allowed models; optional per-model budgets cap individual models.
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
users:
|
|
41
|
+
- email: readonly@example.com
|
|
42
|
+
role: user
|
|
43
|
+
budgets:
|
|
44
|
+
monthlyEur: 10
|
|
45
|
+
models:
|
|
46
|
+
- provider: infomaniak
|
|
47
|
+
id: Qwen/Qwen3.5-122B-A10B-FP8
|
|
48
|
+
monthlyBudgetEur: 2
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A run is admitted only when the user is allowed to use the selected model, the aggregate user budget has remaining monthly capacity, and the selected model budget has remaining monthly capacity.
|
|
52
|
+
|
|
53
|
+
## Governance access matrix smoke
|
|
54
|
+
|
|
55
|
+
The package includes a reusable HTTP matrix runner for deployment smoke tests:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
boring-governance-access-matrix
|
|
59
|
+
# or from the source package:
|
|
60
|
+
pnpm --filter @hachej/boring-governance smoke:governance-matrix
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
It signs in two environment-provided users and verifies the expected read/write behavior for:
|
|
64
|
+
|
|
65
|
+
- company-context public paths
|
|
66
|
+
- company-context Adam-private paths
|
|
67
|
+
- each user's own `user` workspace filesystem
|
|
68
|
+
- cross-user workspace access denial
|
|
69
|
+
|
|
70
|
+
All deployment-specific values must be supplied explicitly; the reusable package intentionally does not ship real credentials or workspace IDs.
|
|
71
|
+
|
|
72
|
+
| Var | Required value |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `MATRIX_BASE_URL` / `DEPLOY_URL` | Deployment base URL |
|
|
75
|
+
| `MATRIX_ADAM_EMAIL` | Admin fixture email |
|
|
76
|
+
| `MATRIX_ADAM_PASSWORD` | Admin fixture password |
|
|
77
|
+
| `MATRIX_ADAM_WORKSPACE_ID` | Admin fixture workspace ID |
|
|
78
|
+
| `MATRIX_READONLY_EMAIL` | Readonly fixture email |
|
|
79
|
+
| `MATRIX_READONLY_PASSWORD` | Readonly fixture password |
|
|
80
|
+
| `MATRIX_READONLY_WORKSPACE_ID` | Readonly fixture workspace ID |
|
|
81
|
+
| `MATRIX_COMPANY_PUBLIC_READ_PATH` | Existing public company-context file readable by both users |
|
|
82
|
+
| `MATRIX_COMPANY_PRIVATE_READ_PATH` | Existing private company-context file readable by admin and denied to readonly |
|
|
83
|
+
| `MATRIX_COMPANY_PUBLIC_WRITE_DIR` | Company-context directory used for admin public write probes |
|
|
84
|
+
| `MATRIX_COMPANY_PRIVATE_WRITE_DIR` | Company-context directory used for admin private write probes |
|
|
85
|
+
| `MATRIX_ADMIN_COMPANY_WRITE_EXPECTED` | Optional admin company-context write status, defaults to `403` for the base readonly plugin |
|
|
86
|
+
| `MATRIX_READONLY_COMPANY_WRITE_EXPECTED` | Optional readonly company-context write status, defaults to `403` |
|
package/dist/front/index.d.ts
CHANGED
package/dist/front/index.js
CHANGED
|
@@ -85,15 +85,17 @@ function GovernanceAdminView({ status }) {
|
|
|
85
85
|
/* @__PURE__ */ jsx("thead", { className: "bg-muted/60 text-xs uppercase tracking-[0.12em] text-muted-foreground", children: /* @__PURE__ */ jsxs("tr", { children: [
|
|
86
86
|
/* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: "User" }),
|
|
87
87
|
/* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: "Role" }),
|
|
88
|
+
/* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: "Monthly budget" }),
|
|
88
89
|
/* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: "Models" }),
|
|
89
90
|
/* @__PURE__ */ jsx("th", { className: "px-3 py-2 font-medium", children: "Context rules" })
|
|
90
91
|
] }) }),
|
|
91
92
|
/* @__PURE__ */ jsx("tbody", { children: (status.users ?? []).length > 0 ? status.users.map((user) => /* @__PURE__ */ jsxs("tr", { className: "border-t border-border", children: [
|
|
92
93
|
/* @__PURE__ */ jsx("td", { className: "px-3 py-2 font-medium", children: user.email }),
|
|
93
94
|
/* @__PURE__ */ jsx("td", { className: "px-3 py-2", children: user.role }),
|
|
95
|
+
/* @__PURE__ */ jsx("td", { className: "px-3 py-2", children: user.monthlyBudgetEur == null ? "\u2014" : `\u20AC${user.monthlyBudgetEur}` }),
|
|
94
96
|
/* @__PURE__ */ jsx("td", { className: "px-3 py-2", children: user.modelCount }),
|
|
95
97
|
/* @__PURE__ */ jsx("td", { className: "px-3 py-2", children: user.contextRuleCount })
|
|
96
|
-
] }, user.email)) : /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", { colSpan:
|
|
98
|
+
] }, user.email)) : /* @__PURE__ */ jsx("tr", { children: /* @__PURE__ */ jsx("td", { colSpan: 5, className: "px-3 py-4 text-muted-foreground", children: "No users in policy." }) }) })
|
|
97
99
|
] }) })
|
|
98
100
|
] }) })
|
|
99
101
|
] }) })
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/scripts/governance-access-matrix.ts
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
var baseUrl = normalizeBaseUrl(readRequiredEnv("MATRIX_BASE_URL", process.env.MATRIX_BASE_URL ?? process.env.DEPLOY_URL));
|
|
6
|
+
var requestTimeoutMs = readPositiveIntEnv("MATRIX_REQUEST_TIMEOUT_MS", 1e4);
|
|
7
|
+
var publicReadPath = readRequiredEnv("MATRIX_COMPANY_PUBLIC_READ_PATH");
|
|
8
|
+
var privateReadPath = readRequiredEnv("MATRIX_COMPANY_PRIVATE_READ_PATH");
|
|
9
|
+
var publicWriteDir = normalizeCompanyDir(readRequiredEnv("MATRIX_COMPANY_PUBLIC_WRITE_DIR"));
|
|
10
|
+
var privateWriteDir = normalizeCompanyDir(readRequiredEnv("MATRIX_COMPANY_PRIVATE_WRITE_DIR"));
|
|
11
|
+
var adminCompanyWriteExpected = readStatusEnv("MATRIX_ADMIN_COMPANY_WRITE_EXPECTED", 403);
|
|
12
|
+
var readonlyCompanyWriteExpected = readStatusEnv("MATRIX_READONLY_COMPANY_WRITE_EXPECTED", 403);
|
|
13
|
+
var runId = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}-${randomUUID().slice(0, 8)}`;
|
|
14
|
+
var users = {
|
|
15
|
+
adam: {
|
|
16
|
+
key: "adam",
|
|
17
|
+
label: "Adam/admin",
|
|
18
|
+
email: readRequiredEnv("MATRIX_ADAM_EMAIL"),
|
|
19
|
+
password: readRequiredSecretEnv("MATRIX_ADAM_PASSWORD"),
|
|
20
|
+
workspaceId: readRequiredEnv("MATRIX_ADAM_WORKSPACE_ID")
|
|
21
|
+
},
|
|
22
|
+
readonly: {
|
|
23
|
+
key: "readonly",
|
|
24
|
+
label: "Readonly",
|
|
25
|
+
email: readRequiredEnv("MATRIX_READONLY_EMAIL"),
|
|
26
|
+
password: readRequiredSecretEnv("MATRIX_READONLY_PASSWORD"),
|
|
27
|
+
workspaceId: readRequiredEnv("MATRIX_READONLY_WORKSPACE_ID")
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
function readRequiredEnv(name, value = process.env[name]) {
|
|
31
|
+
const trimmed = value?.trim();
|
|
32
|
+
if (!trimmed) throw new Error(`${name} is required`);
|
|
33
|
+
return trimmed;
|
|
34
|
+
}
|
|
35
|
+
function readRequiredSecretEnv(name) {
|
|
36
|
+
const value = process.env[name];
|
|
37
|
+
if (value === void 0 || value === "") throw new Error(`${name} is required`);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
function readPositiveIntEnv(name, fallback) {
|
|
41
|
+
const raw = process.env[name]?.trim();
|
|
42
|
+
if (!raw) return fallback;
|
|
43
|
+
const parsed = Number(raw);
|
|
44
|
+
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`);
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
function readStatusEnv(name, fallback) {
|
|
48
|
+
const raw = process.env[name]?.trim();
|
|
49
|
+
if (!raw) return fallback;
|
|
50
|
+
const parsed = Number(raw);
|
|
51
|
+
if (!Number.isInteger(parsed) || parsed < 100 || parsed > 599) throw new Error(`${name} must be an HTTP status code`);
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
function normalizeCompanyDir(value) {
|
|
55
|
+
const raw = value.trim();
|
|
56
|
+
if (raw === "/") return "/";
|
|
57
|
+
const trimmed = raw.replace(/\/+$/, "");
|
|
58
|
+
if (!trimmed.startsWith("/")) throw new Error("company-context fixture directories must be absolute policy paths");
|
|
59
|
+
return trimmed;
|
|
60
|
+
}
|
|
61
|
+
function joinCompanyPath(dir, basename) {
|
|
62
|
+
return dir === "/" ? `/${basename}` : `${dir}/${basename}`;
|
|
63
|
+
}
|
|
64
|
+
async function fetchWithTimeout(input, init = {}) {
|
|
65
|
+
return fetch(input, { ...init, signal: AbortSignal.timeout(requestTimeoutMs) });
|
|
66
|
+
}
|
|
67
|
+
function normalizeBaseUrl(raw) {
|
|
68
|
+
const parsed = new URL(raw);
|
|
69
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
70
|
+
const loopback = ["localhost", "127.0.0.1", "::1"].includes(hostname);
|
|
71
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && (loopback || process.env.MATRIX_ALLOW_INSECURE_HTTP === "1"))) {
|
|
72
|
+
throw new Error("MATRIX_BASE_URL/DEPLOY_URL must be HTTPS unless targeting loopback or MATRIX_ALLOW_INSECURE_HTTP=1 is set");
|
|
73
|
+
}
|
|
74
|
+
return parsed.toString().replace(/\/$/, "");
|
|
75
|
+
}
|
|
76
|
+
function workspaceFor(testUser, scope) {
|
|
77
|
+
if (scope === "own") return testUser.workspaceId;
|
|
78
|
+
return users[scope].workspaceId;
|
|
79
|
+
}
|
|
80
|
+
function cookieFrom(headers) {
|
|
81
|
+
const setCookie = headers.get("set-cookie");
|
|
82
|
+
if (!setCookie) throw new Error("sign-in response did not set a cookie");
|
|
83
|
+
return setCookie.split(/,(?=\s*[^;,\s]+=)/).map((part) => part.split(";")[0]?.trim()).filter(Boolean).join("; ");
|
|
84
|
+
}
|
|
85
|
+
async function signIn(user) {
|
|
86
|
+
const response = await fetchWithTimeout(`${baseUrl}/auth/sign-in/email`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "content-type": "application/json", origin: baseUrl },
|
|
89
|
+
body: JSON.stringify({ email: user.email, password: user.password })
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok) throw new Error(`${user.label} sign-in failed: HTTP ${response.status} ${await response.text()}`);
|
|
92
|
+
return cookieFrom(response.headers);
|
|
93
|
+
}
|
|
94
|
+
async function requestCase(testCase, cookies, cleanupTargets) {
|
|
95
|
+
const cookie = cookies[testCase.user];
|
|
96
|
+
const user = users[testCase.user];
|
|
97
|
+
const workspaceId = workspaceFor(user, testCase.workspace);
|
|
98
|
+
let response;
|
|
99
|
+
if (testCase.operation === "read") {
|
|
100
|
+
const url = new URL(`${baseUrl}/api/v1/files`);
|
|
101
|
+
url.searchParams.set("workspaceId", workspaceId);
|
|
102
|
+
url.searchParams.set("filesystem", testCase.filesystem);
|
|
103
|
+
url.searchParams.set("path", testCase.path);
|
|
104
|
+
response = await fetchWithTimeout(url, { headers: { cookie } });
|
|
105
|
+
} else {
|
|
106
|
+
const content = testCase.content ?? `${testCase.id} ${runId}`;
|
|
107
|
+
cleanupTargets.push({ testCase, cookie: cleanupCookieFor(testCase, cookies) });
|
|
108
|
+
const url = new URL(`${baseUrl}/api/v1/files`);
|
|
109
|
+
url.searchParams.set("workspaceId", workspaceId);
|
|
110
|
+
response = await fetchWithTimeout(url, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { cookie, "content-type": "application/json" },
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
filesystem: testCase.filesystem,
|
|
115
|
+
path: testCase.path,
|
|
116
|
+
content
|
|
117
|
+
})
|
|
118
|
+
});
|
|
119
|
+
const body2 = await response.text();
|
|
120
|
+
if (response.ok && !(testCase.expected >= 200 && testCase.expected < 300)) {
|
|
121
|
+
cleanupTargets.push({ testCase, cookie: cleanupCookieFor(testCase, cookies) });
|
|
122
|
+
}
|
|
123
|
+
const statusMatches = response.status === testCase.expected;
|
|
124
|
+
if (statusMatches && response.ok && testCase.expected >= 200 && testCase.expected < 300) {
|
|
125
|
+
const verified = await verifyWrite(testCase, cookie, workspaceId, content);
|
|
126
|
+
return { ...testCase, actual: response.status, ok: verified, body: verified ? body2 : "write verification failed" };
|
|
127
|
+
}
|
|
128
|
+
if (statusMatches && testCase.expected >= 400) {
|
|
129
|
+
const noSideEffect = await verifyDeniedWriteNoSideEffect(testCase, cleanupCookieFor(testCase, cookies));
|
|
130
|
+
if (!noSideEffect) cleanupTargets.push({ testCase, cookie: cleanupCookieFor(testCase, cookies) });
|
|
131
|
+
return { ...testCase, actual: response.status, ok: noSideEffect, body: noSideEffect ? body2 : "denied write had side effects" };
|
|
132
|
+
}
|
|
133
|
+
return { ...testCase, actual: response.status, ok: statusMatches, body: body2 };
|
|
134
|
+
}
|
|
135
|
+
const body = await response.text();
|
|
136
|
+
return { ...testCase, actual: response.status, ok: response.status === testCase.expected, body };
|
|
137
|
+
}
|
|
138
|
+
function cleanupWorkspaceId(testCase) {
|
|
139
|
+
return testCase.filesystem === "company_context" ? users.adam.workspaceId : workspaceFor(users[testCase.user], testCase.workspace);
|
|
140
|
+
}
|
|
141
|
+
async function verifyWrite(testCase, cookie, workspaceId, content) {
|
|
142
|
+
const url = new URL(`${baseUrl}/api/v1/files`);
|
|
143
|
+
url.searchParams.set("workspaceId", workspaceId);
|
|
144
|
+
url.searchParams.set("filesystem", testCase.filesystem);
|
|
145
|
+
url.searchParams.set("path", testCase.path);
|
|
146
|
+
const response = await fetchWithTimeout(url, { headers: { cookie } });
|
|
147
|
+
if (!response.ok) return false;
|
|
148
|
+
try {
|
|
149
|
+
const parsed = await response.json();
|
|
150
|
+
return parsed.content === content;
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function verifyDeniedWriteNoSideEffect(testCase, cookie) {
|
|
156
|
+
const url = new URL(`${baseUrl}/api/v1/files`);
|
|
157
|
+
url.searchParams.set("workspaceId", cleanupWorkspaceId(testCase));
|
|
158
|
+
url.searchParams.set("filesystem", testCase.filesystem);
|
|
159
|
+
url.searchParams.set("path", testCase.path);
|
|
160
|
+
const response = await fetchWithTimeout(url, { headers: { cookie } });
|
|
161
|
+
return response.status === 404;
|
|
162
|
+
}
|
|
163
|
+
function cleanupCookieFor(testCase, cookies) {
|
|
164
|
+
if (testCase.filesystem !== "user") return cookies.adam;
|
|
165
|
+
if (testCase.workspace === "adam") return cookies.adam;
|
|
166
|
+
if (testCase.workspace === "readonly") return cookies.readonly;
|
|
167
|
+
return cookies[testCase.user];
|
|
168
|
+
}
|
|
169
|
+
async function signOut(cookie) {
|
|
170
|
+
const response = await fetchWithTimeout(`${baseUrl}/auth/sign-out`, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: { cookie, origin: baseUrl, "content-type": "application/json" },
|
|
173
|
+
body: "{}"
|
|
174
|
+
});
|
|
175
|
+
if (!response.ok) throw new Error(`sign-out failed: HTTP ${response.status}`);
|
|
176
|
+
}
|
|
177
|
+
async function cleanupWrite(target) {
|
|
178
|
+
const workspaceId = cleanupWorkspaceId(target.testCase);
|
|
179
|
+
const url = new URL(`${baseUrl}/api/v1/files`);
|
|
180
|
+
url.searchParams.set("workspaceId", workspaceId);
|
|
181
|
+
url.searchParams.set("filesystem", target.testCase.filesystem);
|
|
182
|
+
url.searchParams.set("path", target.testCase.path);
|
|
183
|
+
const response = await fetchWithTimeout(url, { method: "DELETE", headers: { cookie: target.cookie } });
|
|
184
|
+
if (!response.ok && response.status !== 404) {
|
|
185
|
+
if (response.status === 403 && await verifyDeniedWriteNoSideEffect(target.testCase, target.cookie)) return;
|
|
186
|
+
throw new Error(`${target.testCase.id} cleanup failed: HTTP ${response.status}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function buildCases() {
|
|
190
|
+
const adamUserPath = `adam-user-matrix-${runId}.txt`;
|
|
191
|
+
const readonlyUserPath = `readonly-user-matrix-${runId}.txt`;
|
|
192
|
+
const cases = [];
|
|
193
|
+
for (const user of ["adam", "readonly"]) {
|
|
194
|
+
const isAdmin = user === "adam";
|
|
195
|
+
cases.push(
|
|
196
|
+
{
|
|
197
|
+
id: `${user}-read-company-public`,
|
|
198
|
+
user,
|
|
199
|
+
operation: "read",
|
|
200
|
+
filesystem: "company_context",
|
|
201
|
+
location: "company public",
|
|
202
|
+
workspace: "own",
|
|
203
|
+
path: publicReadPath,
|
|
204
|
+
expected: 200,
|
|
205
|
+
note: "public company context is readable by both policy users"
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: `${user}-read-company-adam-private`,
|
|
209
|
+
user,
|
|
210
|
+
operation: "read",
|
|
211
|
+
filesystem: "company_context",
|
|
212
|
+
location: "company adam-private",
|
|
213
|
+
workspace: "own",
|
|
214
|
+
path: privateReadPath,
|
|
215
|
+
expected: isAdmin ? 200 : 404,
|
|
216
|
+
note: "adam-private company context is restricted to Adam/admin"
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: `${user}-write-company-public`,
|
|
220
|
+
user,
|
|
221
|
+
operation: "write",
|
|
222
|
+
filesystem: "company_context",
|
|
223
|
+
location: "company public",
|
|
224
|
+
workspace: "own",
|
|
225
|
+
path: joinCompanyPath(publicWriteDir, `${user}-company-public-write-${runId}.txt`),
|
|
226
|
+
expected: isAdmin ? adminCompanyWriteExpected : readonlyCompanyWriteExpected,
|
|
227
|
+
note: "company context writes require admin"
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
id: `${user}-write-company-adam-private`,
|
|
231
|
+
user,
|
|
232
|
+
operation: "write",
|
|
233
|
+
filesystem: "company_context",
|
|
234
|
+
location: "company adam-private",
|
|
235
|
+
workspace: "own",
|
|
236
|
+
path: joinCompanyPath(privateWriteDir, `${user}-company-private-write-${runId}.txt`),
|
|
237
|
+
expected: isAdmin ? adminCompanyWriteExpected : readonlyCompanyWriteExpected,
|
|
238
|
+
note: "company context writes require admin, including private area"
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
id: `${user}-write-own-user-workspace`,
|
|
242
|
+
user,
|
|
243
|
+
operation: "write",
|
|
244
|
+
filesystem: "user",
|
|
245
|
+
location: "own user workspace",
|
|
246
|
+
workspace: "own",
|
|
247
|
+
path: user === "adam" ? adamUserPath : readonlyUserPath,
|
|
248
|
+
expected: 200,
|
|
249
|
+
note: "each user can write their own workspace"
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
id: `${user}-read-own-user-workspace`,
|
|
253
|
+
user,
|
|
254
|
+
operation: "read",
|
|
255
|
+
filesystem: "user",
|
|
256
|
+
location: "own user workspace",
|
|
257
|
+
workspace: "own",
|
|
258
|
+
path: user === "adam" ? adamUserPath : readonlyUserPath,
|
|
259
|
+
expected: 200,
|
|
260
|
+
note: "each user can read their own workspace"
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
id: `${user}-write-other-user-workspace`,
|
|
264
|
+
user,
|
|
265
|
+
operation: "write",
|
|
266
|
+
filesystem: "user",
|
|
267
|
+
location: "other user workspace",
|
|
268
|
+
workspace: user === "adam" ? "readonly" : "adam",
|
|
269
|
+
path: `${user}-cross-workspace-write-${runId}.txt`,
|
|
270
|
+
expected: 403,
|
|
271
|
+
note: "workspace membership blocks writes to the other user workspace"
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
id: `${user}-read-other-user-workspace`,
|
|
275
|
+
user,
|
|
276
|
+
operation: "read",
|
|
277
|
+
filesystem: "user",
|
|
278
|
+
location: "other user workspace",
|
|
279
|
+
workspace: user === "adam" ? "readonly" : "adam",
|
|
280
|
+
path: user === "adam" ? readonlyUserPath : adamUserPath,
|
|
281
|
+
expected: 403,
|
|
282
|
+
note: "workspace membership blocks reads from the other user workspace"
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return cases;
|
|
287
|
+
}
|
|
288
|
+
function compactBody(body, actual) {
|
|
289
|
+
if (body === "write verification failed" || body === "denied write had side effects") return body;
|
|
290
|
+
if (actual >= 200 && actual < 300) return "[success body redacted]";
|
|
291
|
+
try {
|
|
292
|
+
const parsed = JSON.parse(body);
|
|
293
|
+
const code = typeof parsed.error?.code === "string" ? parsed.error.code : typeof parsed.code === "string" ? parsed.code : "error";
|
|
294
|
+
const message = typeof parsed.error?.message === "string" ? parsed.error.message : typeof parsed.message === "string" ? parsed.message : "";
|
|
295
|
+
return [code, message].filter(Boolean).join(": ").slice(0, 100);
|
|
296
|
+
} catch {
|
|
297
|
+
return "[error body redacted]";
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function printMarkdown(results) {
|
|
301
|
+
console.log(`
|
|
302
|
+
Governance access matrix (${baseUrl})
|
|
303
|
+
`);
|
|
304
|
+
console.log("| User | Operation | Filesystem | Location | Workspace | Path | Expected | Actual | Result |");
|
|
305
|
+
console.log("|---|---:|---|---|---|---|---:|---:|---|");
|
|
306
|
+
for (const result of results) {
|
|
307
|
+
console.log(`| ${users[result.user].label} | ${result.operation} | \`${result.filesystem}\` | ${result.location} | ${result.workspace} | \`${result.path}\` | ${result.expected} | ${result.actual} | ${result.ok ? "\u2705" : `\u274C ${compactBody(result.body, result.actual)}`} |`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async function main() {
|
|
311
|
+
const cookies = {};
|
|
312
|
+
const cleanupTargets = [];
|
|
313
|
+
const results = [];
|
|
314
|
+
const cleanupFailures = [];
|
|
315
|
+
let primaryError = null;
|
|
316
|
+
try {
|
|
317
|
+
cookies.adam = await signIn(users.adam);
|
|
318
|
+
cookies.readonly = await signIn(users.readonly);
|
|
319
|
+
const completeCookies = cookies;
|
|
320
|
+
for (const testCase of buildCases()) {
|
|
321
|
+
results.push(await requestCase(testCase, completeCookies, cleanupTargets));
|
|
322
|
+
}
|
|
323
|
+
} catch (error) {
|
|
324
|
+
primaryError = error;
|
|
325
|
+
} finally {
|
|
326
|
+
for (const target of cleanupTargets.reverse()) {
|
|
327
|
+
await cleanupWrite(target).catch((error) => {
|
|
328
|
+
cleanupFailures.push(error instanceof Error ? error.message : String(error));
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
await Promise.all(Object.values(cookies).filter((cookie) => Boolean(cookie)).map((cookie) => signOut(cookie).catch((error) => {
|
|
332
|
+
cleanupFailures.push(error instanceof Error ? error.message : String(error));
|
|
333
|
+
})));
|
|
334
|
+
}
|
|
335
|
+
printMarkdown(results);
|
|
336
|
+
const failures = results.filter((result) => !result.ok);
|
|
337
|
+
if (primaryError || failures.length > 0 || cleanupFailures.length > 0) {
|
|
338
|
+
if (primaryError) console.error(primaryError instanceof Error ? primaryError.stack ?? primaryError.message : String(primaryError));
|
|
339
|
+
for (const failure of cleanupFailures) console.error(`cleanup failed: ${failure}`);
|
|
340
|
+
if (failures.length > 0) console.error(`
|
|
341
|
+
${failures.length} governance access matrix case(s) failed.`);
|
|
342
|
+
if (cleanupFailures.length > 0) console.error(`${cleanupFailures.length} cleanup operation(s) failed.`);
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
console.log("\nAll governance access matrix cases passed.");
|
|
346
|
+
}
|
|
347
|
+
main().catch((error) => {
|
|
348
|
+
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
|
|
349
|
+
process.exit(1);
|
|
350
|
+
});
|
package/dist/server/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as _hachej_boring_agent_server from '@hachej/boring-agent/server';
|
|
|
2
2
|
import { AgentMeteringSink, RegisterAgentRoutesOptions } from '@hachej/boring-agent/server';
|
|
3
3
|
import { CoreConfig } from '@hachej/boring-core/shared';
|
|
4
4
|
import { CoreWorkspaceAgentServerPlugin } from '@hachej/boring-core/app/server';
|
|
5
|
+
import { PostgresBudgetReservationStore } from '@hachej/boring-core/server';
|
|
5
6
|
import { FastifyRequest } from 'fastify';
|
|
6
7
|
|
|
7
8
|
type TenantRole = 'admin' | 'user';
|
|
@@ -11,9 +12,14 @@ interface GovernanceModelGrant {
|
|
|
11
12
|
monthlyBudgetEur: number;
|
|
12
13
|
monthlyBudgetMicros: number;
|
|
13
14
|
}
|
|
15
|
+
interface GovernanceUserBudgets {
|
|
16
|
+
monthlyEur: number | null;
|
|
17
|
+
monthlyMicros: number | null;
|
|
18
|
+
}
|
|
14
19
|
interface GovernanceUserPolicy {
|
|
15
20
|
email: string;
|
|
16
21
|
role: TenantRole;
|
|
22
|
+
budgets: GovernanceUserBudgets;
|
|
17
23
|
models: GovernanceModelGrant[];
|
|
18
24
|
companyContext: {
|
|
19
25
|
allow: string[];
|
|
@@ -69,6 +75,7 @@ interface LoadGovernancePolicyOptions {
|
|
|
69
75
|
declare function loadGovernancePolicy({ env, nodeEnv, config, }?: LoadGovernancePolicyOptions): Promise<GovernanceLoadResult>;
|
|
70
76
|
|
|
71
77
|
type GovernancePolicyErrorCode = 'disabled' | 'invalid' | 'denied' | 'not_allowed';
|
|
78
|
+
type CompanyContextAccess = 'none' | 'readonly' | 'readwrite';
|
|
72
79
|
declare class GovernancePolicyError extends Error {
|
|
73
80
|
readonly code: GovernancePolicyErrorCode;
|
|
74
81
|
constructor(message: string, code: GovernancePolicyErrorCode);
|
|
@@ -88,6 +95,7 @@ interface GovernanceMeResponse {
|
|
|
88
95
|
email: string;
|
|
89
96
|
role: TenantRole;
|
|
90
97
|
modelCount: number;
|
|
98
|
+
monthlyBudgetEur: number | null;
|
|
91
99
|
contextRuleCount: number;
|
|
92
100
|
}>;
|
|
93
101
|
models?: Array<GovernanceModelGrant & {
|
|
@@ -111,22 +119,17 @@ declare class GovernanceService {
|
|
|
111
119
|
allowedModelsForUser<TModel extends ServedModelLike>(user: GovernanceUserLike, servedModels: readonly TModel[]): TModel[];
|
|
112
120
|
assertModelAllowed(user: GovernanceUserLike, model: ServedModelLike): void;
|
|
113
121
|
monthlyBudgetMicros(user: GovernanceUserLike, model: ServedModelLike): number | null;
|
|
122
|
+
userMonthlyBudgetMicros(user: GovernanceUserLike): number | null;
|
|
114
123
|
companyContextRules(user: GovernanceUserLike): string[];
|
|
124
|
+
companyContextAccessForUser(user: GovernanceUserLike | null | undefined): CompanyContextAccess;
|
|
115
125
|
companyContextWorkspaceId(): string | null;
|
|
116
126
|
me(user: GovernanceUserLike | null | undefined): GovernanceMeResponse;
|
|
117
127
|
}
|
|
118
128
|
|
|
119
|
-
|
|
120
|
-
reserve(input: unknown): Promise<{
|
|
121
|
-
reservationId: string;
|
|
122
|
-
}>;
|
|
123
|
-
settle(input: unknown): Promise<void>;
|
|
124
|
-
release(input: unknown): Promise<void>;
|
|
125
|
-
};
|
|
126
|
-
type ModelBudgetDb = ConstructorParameters<typeof PostgresModelBudgetStore>[0];
|
|
129
|
+
type BudgetDb = ConstructorParameters<typeof PostgresBudgetReservationStore>[0];
|
|
127
130
|
declare function createGovernanceMeteringSink(options: {
|
|
128
131
|
service: GovernanceService;
|
|
129
|
-
getDb: () =>
|
|
132
|
+
getDb: () => BudgetDb;
|
|
130
133
|
delegate: AgentMeteringSink;
|
|
131
134
|
holdTtlSeconds?: number;
|
|
132
135
|
}): AgentMeteringSink;
|
|
@@ -145,6 +148,8 @@ type CompanyContextRootResolver = (ctx: GovernanceFilesystemBindingContext, comp
|
|
|
145
148
|
interface CreateGovernanceFilesystemBindingsOptions {
|
|
146
149
|
/** Explicit source root resolver for the tenant company-context workspace. */
|
|
147
150
|
resolveCompanyContextRoot?: CompanyContextRootResolver;
|
|
151
|
+
/** Enable admin mutations only when the resolved root is exclusively managed by the governance service. */
|
|
152
|
+
allowAdminMutations?: boolean;
|
|
148
153
|
projectionRootParent?: string;
|
|
149
154
|
}
|
|
150
155
|
declare function createDefaultCompanyContextRootResolver(env?: NodeJS.ProcessEnv): CompanyContextRootResolver | undefined;
|