scene-capability-engine 3.6.2 → 3.6.4
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/CHANGELOG.md +54 -0
- package/README.md +15 -2
- package/README.zh.md +15 -2
- package/bin/scene-capability-engine.js +33 -1
- package/docs/command-reference.md +87 -0
- package/lib/collab/agent-registry.js +38 -1
- package/lib/commands/auth.js +269 -0
- package/lib/commands/session.js +60 -2
- package/lib/commands/state.js +210 -0
- package/lib/commands/studio.js +57 -7
- package/lib/commands/task.js +25 -2
- package/lib/runtime/project-timeline.js +202 -17
- package/lib/runtime/session-store.js +167 -14
- package/lib/security/write-authorization.js +632 -0
- package/lib/state/sce-state-store.js +1029 -1
- package/lib/state/state-migration-manager.js +659 -0
- package/lib/steering/compliance-error-reporter.js +6 -0
- package/lib/steering/steering-compliance-checker.js +43 -8
- package/package.json +2 -1
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
const os = require('os');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const { getSceStateStore } = require('../state/sce-state-store');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_WRITE_AUTH_POLICY_PATH = '.sce/config/authorization-policy.json';
|
|
7
|
+
const DEFAULT_WRITE_AUTH_POLICY = Object.freeze({
|
|
8
|
+
enabled: false,
|
|
9
|
+
enforce_actions: ['studio:apply', 'studio:release', 'studio:rollback', 'task:rerun'],
|
|
10
|
+
default_ttl_minutes: 15,
|
|
11
|
+
max_ttl_minutes: 120,
|
|
12
|
+
require_password_for_grant: true,
|
|
13
|
+
require_password_for_revoke: false,
|
|
14
|
+
password_env: 'SCE_AUTH_PASSWORD',
|
|
15
|
+
default_scope: ['project:*'],
|
|
16
|
+
allow_test_bypass: true,
|
|
17
|
+
allow_password_as_inline_lease: false
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function normalizeString(value) {
|
|
21
|
+
if (typeof value !== 'string') {
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
return value.trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeBoolean(value, fallback = false) {
|
|
28
|
+
if (typeof value === 'boolean') {
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const normalized = normalizeString(`${value}`).toLowerCase();
|
|
33
|
+
if (!normalized) {
|
|
34
|
+
return fallback;
|
|
35
|
+
}
|
|
36
|
+
if (['1', 'true', 'yes', 'on', 'enabled'].includes(normalized)) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
if (['0', 'false', 'no', 'off', 'disabled'].includes(normalized)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return fallback;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeInteger(value, fallback = 0) {
|
|
46
|
+
const parsed = Number.parseInt(`${value}`, 10);
|
|
47
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeStringArray(value, fallback = []) {
|
|
54
|
+
if (Array.isArray(value)) {
|
|
55
|
+
const normalized = value
|
|
56
|
+
.map((item) => normalizeString(item))
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
return normalized.length > 0 ? normalized : [...fallback];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const text = normalizeString(value);
|
|
62
|
+
if (!text) {
|
|
63
|
+
return [...fallback];
|
|
64
|
+
}
|
|
65
|
+
const split = text
|
|
66
|
+
.split(/[,\s]+/g)
|
|
67
|
+
.map((item) => normalizeString(item))
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
return split.length > 0 ? split : [...fallback];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeScopeList(value, fallback = ['project:*']) {
|
|
73
|
+
const normalized = normalizeStringArray(value, fallback)
|
|
74
|
+
.map((item) => item.toLowerCase())
|
|
75
|
+
.map((item) => item.replace(/\s+/g, ''))
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
return normalized.length > 0 ? Array.from(new Set(normalized)) : [...fallback];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function nowIso() {
|
|
81
|
+
return new Date().toISOString();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveActor(env = process.env) {
|
|
85
|
+
const preferred = normalizeString(env.SCE_AUTH_ACTOR)
|
|
86
|
+
|| normalizeString(env.SCE_AUTH_SUBJECT)
|
|
87
|
+
|| normalizeString(env.USERNAME)
|
|
88
|
+
|| normalizeString(env.USER);
|
|
89
|
+
if (preferred) {
|
|
90
|
+
return preferred;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
return normalizeString(os.userInfo().username) || 'unknown';
|
|
95
|
+
} catch (_error) {
|
|
96
|
+
return 'unknown';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function scopeMatchesAction(scope, action) {
|
|
101
|
+
const normalizedScope = normalizeString(scope).toLowerCase();
|
|
102
|
+
const normalizedAction = normalizeString(action).toLowerCase();
|
|
103
|
+
if (!normalizedScope || !normalizedAction) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
if (normalizedScope === '*' || normalizedScope === 'project:*') {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
if (normalizedScope === normalizedAction) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
if (normalizedScope.endsWith(':*')) {
|
|
113
|
+
const prefix = normalizedScope.slice(0, -1);
|
|
114
|
+
return normalizedAction.startsWith(prefix);
|
|
115
|
+
}
|
|
116
|
+
if (normalizedScope.endsWith('*')) {
|
|
117
|
+
const prefix = normalizedScope.slice(0, -1);
|
|
118
|
+
return normalizedAction.startsWith(prefix);
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function hasScopeForAction(scopeList = [], action = '') {
|
|
124
|
+
const normalizedScopes = normalizeScopeList(scopeList, []);
|
|
125
|
+
if (normalizedScopes.length === 0) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return normalizedScopes.some((scope) => scopeMatchesAction(scope, action));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizeWriteAuthPolicy(rawPolicy = {}) {
|
|
132
|
+
const enabled = normalizeBoolean(rawPolicy.enabled, DEFAULT_WRITE_AUTH_POLICY.enabled);
|
|
133
|
+
const enforceActions = normalizeScopeList(
|
|
134
|
+
rawPolicy.enforce_actions,
|
|
135
|
+
DEFAULT_WRITE_AUTH_POLICY.enforce_actions
|
|
136
|
+
);
|
|
137
|
+
const defaultScope = normalizeScopeList(
|
|
138
|
+
rawPolicy.default_scope,
|
|
139
|
+
DEFAULT_WRITE_AUTH_POLICY.default_scope
|
|
140
|
+
);
|
|
141
|
+
const defaultTtlMinutes = normalizeInteger(
|
|
142
|
+
rawPolicy.default_ttl_minutes,
|
|
143
|
+
DEFAULT_WRITE_AUTH_POLICY.default_ttl_minutes
|
|
144
|
+
);
|
|
145
|
+
const maxTtlMinutes = Math.max(
|
|
146
|
+
normalizeInteger(rawPolicy.max_ttl_minutes, DEFAULT_WRITE_AUTH_POLICY.max_ttl_minutes),
|
|
147
|
+
defaultTtlMinutes
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
enabled,
|
|
152
|
+
enforce_actions: enforceActions,
|
|
153
|
+
default_ttl_minutes: defaultTtlMinutes,
|
|
154
|
+
max_ttl_minutes: maxTtlMinutes,
|
|
155
|
+
require_password_for_grant: normalizeBoolean(
|
|
156
|
+
rawPolicy.require_password_for_grant,
|
|
157
|
+
DEFAULT_WRITE_AUTH_POLICY.require_password_for_grant
|
|
158
|
+
),
|
|
159
|
+
require_password_for_revoke: normalizeBoolean(
|
|
160
|
+
rawPolicy.require_password_for_revoke,
|
|
161
|
+
DEFAULT_WRITE_AUTH_POLICY.require_password_for_revoke
|
|
162
|
+
),
|
|
163
|
+
password_env: normalizeString(rawPolicy.password_env) || DEFAULT_WRITE_AUTH_POLICY.password_env,
|
|
164
|
+
default_scope: defaultScope,
|
|
165
|
+
allow_test_bypass: normalizeBoolean(
|
|
166
|
+
rawPolicy.allow_test_bypass,
|
|
167
|
+
DEFAULT_WRITE_AUTH_POLICY.allow_test_bypass
|
|
168
|
+
),
|
|
169
|
+
allow_password_as_inline_lease: normalizeBoolean(
|
|
170
|
+
rawPolicy.allow_password_as_inline_lease,
|
|
171
|
+
DEFAULT_WRITE_AUTH_POLICY.allow_password_as_inline_lease
|
|
172
|
+
)
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function applyPolicyEnvOverrides(policy = {}, env = process.env) {
|
|
177
|
+
const overrideEnabled = normalizeString(env.SCE_AUTH_REQUIRE_LEASE);
|
|
178
|
+
const overridePasswordEnv = normalizeString(env.SCE_AUTH_PASSWORD_ENV);
|
|
179
|
+
const overrideEnforceActions = normalizeString(env.SCE_AUTH_ENFORCE_ACTIONS);
|
|
180
|
+
const overrideDefaultTtl = normalizeString(env.SCE_AUTH_DEFAULT_TTL_MINUTES);
|
|
181
|
+
const overrideMaxTtl = normalizeString(env.SCE_AUTH_MAX_TTL_MINUTES);
|
|
182
|
+
|
|
183
|
+
return normalizeWriteAuthPolicy({
|
|
184
|
+
...policy,
|
|
185
|
+
enabled: overrideEnabled ? normalizeBoolean(overrideEnabled, policy.enabled) : policy.enabled,
|
|
186
|
+
password_env: overridePasswordEnv || policy.password_env,
|
|
187
|
+
enforce_actions: overrideEnforceActions
|
|
188
|
+
? normalizeScopeList(overrideEnforceActions, policy.enforce_actions)
|
|
189
|
+
: policy.enforce_actions,
|
|
190
|
+
default_ttl_minutes: overrideDefaultTtl
|
|
191
|
+
? normalizeInteger(overrideDefaultTtl, policy.default_ttl_minutes)
|
|
192
|
+
: policy.default_ttl_minutes,
|
|
193
|
+
max_ttl_minutes: overrideMaxTtl
|
|
194
|
+
? normalizeInteger(overrideMaxTtl, policy.max_ttl_minutes)
|
|
195
|
+
: policy.max_ttl_minutes,
|
|
196
|
+
require_password_for_grant: normalizeString(env.SCE_AUTH_REQUIRE_PASSWORD_FOR_GRANT)
|
|
197
|
+
? normalizeBoolean(env.SCE_AUTH_REQUIRE_PASSWORD_FOR_GRANT, policy.require_password_for_grant)
|
|
198
|
+
: policy.require_password_for_grant,
|
|
199
|
+
require_password_for_revoke: normalizeString(env.SCE_AUTH_REQUIRE_PASSWORD_FOR_REVOKE)
|
|
200
|
+
? normalizeBoolean(env.SCE_AUTH_REQUIRE_PASSWORD_FOR_REVOKE, policy.require_password_for_revoke)
|
|
201
|
+
: policy.require_password_for_revoke,
|
|
202
|
+
allow_test_bypass: normalizeString(env.SCE_AUTH_ALLOW_TEST_BYPASS)
|
|
203
|
+
? normalizeBoolean(env.SCE_AUTH_ALLOW_TEST_BYPASS, policy.allow_test_bypass)
|
|
204
|
+
: policy.allow_test_bypass,
|
|
205
|
+
allow_password_as_inline_lease: normalizeString(env.SCE_AUTH_INLINE_PASSWORD_LEASE)
|
|
206
|
+
? normalizeBoolean(env.SCE_AUTH_INLINE_PASSWORD_LEASE, policy.allow_password_as_inline_lease)
|
|
207
|
+
: policy.allow_password_as_inline_lease
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sanitizePolicyForOutput(policy = {}) {
|
|
212
|
+
return {
|
|
213
|
+
enabled: policy.enabled === true,
|
|
214
|
+
enforce_actions: normalizeScopeList(policy.enforce_actions, DEFAULT_WRITE_AUTH_POLICY.enforce_actions),
|
|
215
|
+
default_ttl_minutes: normalizeInteger(policy.default_ttl_minutes, DEFAULT_WRITE_AUTH_POLICY.default_ttl_minutes),
|
|
216
|
+
max_ttl_minutes: normalizeInteger(policy.max_ttl_minutes, DEFAULT_WRITE_AUTH_POLICY.max_ttl_minutes),
|
|
217
|
+
require_password_for_grant: policy.require_password_for_grant === true,
|
|
218
|
+
require_password_for_revoke: policy.require_password_for_revoke === true,
|
|
219
|
+
password_env: normalizeString(policy.password_env) || DEFAULT_WRITE_AUTH_POLICY.password_env,
|
|
220
|
+
default_scope: normalizeScopeList(policy.default_scope, DEFAULT_WRITE_AUTH_POLICY.default_scope),
|
|
221
|
+
allow_test_bypass: policy.allow_test_bypass === true,
|
|
222
|
+
allow_password_as_inline_lease: policy.allow_password_as_inline_lease === true
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function loadWriteAuthorizationPolicy(projectPath = process.cwd(), fileSystem = fs, env = process.env) {
|
|
227
|
+
const policyPath = path.join(projectPath, DEFAULT_WRITE_AUTH_POLICY_PATH);
|
|
228
|
+
let filePolicy = {};
|
|
229
|
+
|
|
230
|
+
if (await fileSystem.pathExists(policyPath)) {
|
|
231
|
+
try {
|
|
232
|
+
filePolicy = await fileSystem.readJson(policyPath);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
throw new Error(`Failed to read write authorization policy: ${error.message}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const merged = normalizeWriteAuthPolicy({
|
|
239
|
+
...DEFAULT_WRITE_AUTH_POLICY,
|
|
240
|
+
...(filePolicy || {})
|
|
241
|
+
});
|
|
242
|
+
const normalized = applyPolicyEnvOverrides(merged, env);
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
policy: normalized,
|
|
246
|
+
policy_path: DEFAULT_WRITE_AUTH_POLICY_PATH
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function shouldEnforceAction(policy = {}, action = '', options = {}) {
|
|
251
|
+
if (options.requireAuth === true) {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
if (policy.enabled !== true) {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
return hasScopeForAction(policy.enforce_actions || [], action);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function resolveStore(projectPath, dependencies = {}) {
|
|
261
|
+
return getSceStateStore(projectPath, {
|
|
262
|
+
fileSystem: dependencies.fileSystem || fs,
|
|
263
|
+
env: dependencies.env || process.env,
|
|
264
|
+
sqliteModule: dependencies.sqliteModule
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function ensureLeaseActive(lease = {}, now = nowIso()) {
|
|
269
|
+
if (!lease || typeof lease !== 'object') {
|
|
270
|
+
return { ok: false, reason: 'lease_not_found' };
|
|
271
|
+
}
|
|
272
|
+
if (normalizeString(lease.revoked_at)) {
|
|
273
|
+
return { ok: false, reason: 'lease_revoked' };
|
|
274
|
+
}
|
|
275
|
+
const expiresAt = Date.parse(normalizeString(lease.expires_at));
|
|
276
|
+
const nowTs = Date.parse(normalizeString(now));
|
|
277
|
+
if (!Number.isFinite(expiresAt) || !Number.isFinite(nowTs) || expiresAt <= nowTs) {
|
|
278
|
+
return { ok: false, reason: 'lease_expired' };
|
|
279
|
+
}
|
|
280
|
+
return { ok: true, reason: '' };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function appendAuthAuditEvent(stateStore, payload = {}) {
|
|
284
|
+
const appended = await stateStore.appendAuthEvent(payload);
|
|
285
|
+
if (!appended) {
|
|
286
|
+
throw new Error('Failed to persist authorization audit event into sqlite state store');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function ensurePolicyPassword(options = {}, dependencies = {}, policy = {}, actionLabel = 'grant') {
|
|
291
|
+
const env = dependencies.env || process.env;
|
|
292
|
+
const passwordEnv = normalizeString(policy.password_env) || DEFAULT_WRITE_AUTH_POLICY.password_env;
|
|
293
|
+
const expected = normalizeString(dependencies.authSecret || env[passwordEnv]);
|
|
294
|
+
if (!expected) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Authorization policy requires password for ${actionLabel}, but ${passwordEnv} is not configured`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const provided = normalizeString(options.authPassword);
|
|
301
|
+
if (!provided) {
|
|
302
|
+
throw new Error(`Authorization password required for ${actionLabel}. Provide --auth-password`);
|
|
303
|
+
}
|
|
304
|
+
if (provided !== expected) {
|
|
305
|
+
throw new Error(`Authorization password check failed for ${actionLabel}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return passwordEnv;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function normalizeTtlMinutes(ttlValue, policy = {}) {
|
|
312
|
+
const defaultTtl = normalizeInteger(policy.default_ttl_minutes, DEFAULT_WRITE_AUTH_POLICY.default_ttl_minutes);
|
|
313
|
+
const maxTtl = normalizeInteger(policy.max_ttl_minutes, DEFAULT_WRITE_AUTH_POLICY.max_ttl_minutes);
|
|
314
|
+
const requested = normalizeInteger(ttlValue, defaultTtl);
|
|
315
|
+
return Math.min(Math.max(requested, 1), Math.max(maxTtl, 1));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function grantWriteAuthorizationLease(options = {}, dependencies = {}) {
|
|
319
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
320
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
321
|
+
const env = dependencies.env || process.env;
|
|
322
|
+
const loadedPolicy = await loadWriteAuthorizationPolicy(projectPath, fileSystem, env);
|
|
323
|
+
const policy = loadedPolicy.policy;
|
|
324
|
+
|
|
325
|
+
let passwordEnv = null;
|
|
326
|
+
if (policy.require_password_for_grant) {
|
|
327
|
+
passwordEnv = ensurePolicyPassword(options, dependencies, policy, 'auth grant');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const ttlMinutes = normalizeTtlMinutes(options.ttlMinutes || options.ttl_minutes, policy);
|
|
331
|
+
const actor = normalizeString(options.actor) || resolveActor(env);
|
|
332
|
+
const subject = normalizeString(options.subject) || actor;
|
|
333
|
+
const role = normalizeString(options.role) || 'maintainer';
|
|
334
|
+
const scope = normalizeScopeList(options.scope, policy.default_scope);
|
|
335
|
+
const reason = normalizeString(options.reason) || 'manual-auth-grant';
|
|
336
|
+
const metadata = options.metadata && typeof options.metadata === 'object'
|
|
337
|
+
? { ...options.metadata }
|
|
338
|
+
: {};
|
|
339
|
+
|
|
340
|
+
const stateStore = resolveStore(projectPath, { ...dependencies, fileSystem, env });
|
|
341
|
+
const lease = await stateStore.issueAuthLease({
|
|
342
|
+
subject,
|
|
343
|
+
role,
|
|
344
|
+
scope,
|
|
345
|
+
reason,
|
|
346
|
+
metadata: {
|
|
347
|
+
...metadata,
|
|
348
|
+
actor,
|
|
349
|
+
source: metadata.source || 'sce auth grant'
|
|
350
|
+
},
|
|
351
|
+
ttl_minutes: ttlMinutes
|
|
352
|
+
});
|
|
353
|
+
if (!lease) {
|
|
354
|
+
throw new Error('SQLite state backend unavailable while issuing auth lease');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
await appendAuthAuditEvent(stateStore, {
|
|
358
|
+
event_type: 'lease.granted',
|
|
359
|
+
action: 'auth:grant',
|
|
360
|
+
actor,
|
|
361
|
+
lease_id: lease.lease_id,
|
|
362
|
+
result: 'allow',
|
|
363
|
+
target: subject,
|
|
364
|
+
detail: {
|
|
365
|
+
role,
|
|
366
|
+
scope,
|
|
367
|
+
reason,
|
|
368
|
+
ttl_minutes: ttlMinutes
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
success: true,
|
|
374
|
+
policy: sanitizePolicyForOutput(policy),
|
|
375
|
+
policy_path: loadedPolicy.policy_path,
|
|
376
|
+
password_env: passwordEnv,
|
|
377
|
+
lease,
|
|
378
|
+
store_path: stateStore.getStoreRelativePath()
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function revokeWriteAuthorizationLease(leaseId, options = {}, dependencies = {}) {
|
|
383
|
+
const normalizedLeaseId = normalizeString(leaseId);
|
|
384
|
+
if (!normalizedLeaseId) {
|
|
385
|
+
throw new Error('lease id is required');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
389
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
390
|
+
const env = dependencies.env || process.env;
|
|
391
|
+
const loadedPolicy = await loadWriteAuthorizationPolicy(projectPath, fileSystem, env);
|
|
392
|
+
const policy = loadedPolicy.policy;
|
|
393
|
+
|
|
394
|
+
if (policy.require_password_for_revoke) {
|
|
395
|
+
ensurePolicyPassword(options, dependencies, policy, 'auth revoke');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const actor = normalizeString(options.actor) || resolveActor(env);
|
|
399
|
+
const reason = normalizeString(options.reason) || 'manual-auth-revoke';
|
|
400
|
+
const stateStore = resolveStore(projectPath, { ...dependencies, fileSystem, env });
|
|
401
|
+
|
|
402
|
+
const lease = await stateStore.revokeAuthLease(normalizedLeaseId);
|
|
403
|
+
if (!lease) {
|
|
404
|
+
throw new Error(`Auth lease not found: ${normalizedLeaseId}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
await appendAuthAuditEvent(stateStore, {
|
|
408
|
+
event_type: 'lease.revoked',
|
|
409
|
+
action: 'auth:revoke',
|
|
410
|
+
actor,
|
|
411
|
+
lease_id: normalizedLeaseId,
|
|
412
|
+
result: 'allow',
|
|
413
|
+
target: lease.subject || null,
|
|
414
|
+
detail: {
|
|
415
|
+
reason
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
success: true,
|
|
421
|
+
policy: sanitizePolicyForOutput(policy),
|
|
422
|
+
policy_path: loadedPolicy.policy_path,
|
|
423
|
+
lease,
|
|
424
|
+
store_path: stateStore.getStoreRelativePath()
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function ensureWriteAuthorization(action, options = {}, dependencies = {}) {
|
|
429
|
+
const normalizedAction = normalizeString(action).toLowerCase();
|
|
430
|
+
if (!normalizedAction) {
|
|
431
|
+
throw new Error('write authorization action is required');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
435
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
436
|
+
const env = dependencies.env || process.env;
|
|
437
|
+
const loadedPolicy = await loadWriteAuthorizationPolicy(projectPath, fileSystem, env);
|
|
438
|
+
const policy = loadedPolicy.policy;
|
|
439
|
+
const enforce = shouldEnforceAction(policy, normalizedAction, options);
|
|
440
|
+
|
|
441
|
+
if (!enforce) {
|
|
442
|
+
return {
|
|
443
|
+
required: false,
|
|
444
|
+
passed: true,
|
|
445
|
+
action: normalizedAction,
|
|
446
|
+
policy: sanitizePolicyForOutput(policy),
|
|
447
|
+
policy_path: loadedPolicy.policy_path
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (policy.allow_test_bypass && normalizeString(env.NODE_ENV).toLowerCase() === 'test') {
|
|
452
|
+
return {
|
|
453
|
+
required: true,
|
|
454
|
+
passed: true,
|
|
455
|
+
bypassed: 'test-env',
|
|
456
|
+
action: normalizedAction,
|
|
457
|
+
policy: sanitizePolicyForOutput(policy),
|
|
458
|
+
policy_path: loadedPolicy.policy_path
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const actor = normalizeString(options.actor) || resolveActor(env);
|
|
463
|
+
const stateStore = resolveStore(projectPath, { ...dependencies, fileSystem, env });
|
|
464
|
+
let leaseId = normalizeString(options.authLease || options.authLeaseId);
|
|
465
|
+
|
|
466
|
+
if (!leaseId && policy.allow_password_as_inline_lease && normalizeString(options.authPassword)) {
|
|
467
|
+
const granted = await grantWriteAuthorizationLease({
|
|
468
|
+
subject: actor,
|
|
469
|
+
role: 'maintainer',
|
|
470
|
+
scope: [normalizedAction],
|
|
471
|
+
reason: `inline-auth:${normalizedAction}`,
|
|
472
|
+
authPassword: options.authPassword,
|
|
473
|
+
metadata: {
|
|
474
|
+
source: `inline:${normalizedAction}`
|
|
475
|
+
}
|
|
476
|
+
}, {
|
|
477
|
+
projectPath,
|
|
478
|
+
fileSystem,
|
|
479
|
+
env,
|
|
480
|
+
authSecret: dependencies.authSecret
|
|
481
|
+
});
|
|
482
|
+
leaseId = normalizeString(granted?.lease?.lease_id);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!leaseId) {
|
|
486
|
+
await appendAuthAuditEvent(stateStore, {
|
|
487
|
+
event_type: 'authorization.denied',
|
|
488
|
+
action: normalizedAction,
|
|
489
|
+
actor,
|
|
490
|
+
result: 'deny',
|
|
491
|
+
detail: {
|
|
492
|
+
reason: 'lease_required'
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
throw new Error(
|
|
496
|
+
`Write authorization required for ${normalizedAction}. Run: sce auth grant --scope ${normalizedAction} --reason "<reason>"`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const lease = await stateStore.getAuthLease(leaseId);
|
|
501
|
+
if (!lease) {
|
|
502
|
+
await appendAuthAuditEvent(stateStore, {
|
|
503
|
+
event_type: 'authorization.denied',
|
|
504
|
+
action: normalizedAction,
|
|
505
|
+
actor,
|
|
506
|
+
lease_id: leaseId,
|
|
507
|
+
result: 'deny',
|
|
508
|
+
detail: {
|
|
509
|
+
reason: 'lease_not_found'
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
throw new Error(`Write authorization denied for ${normalizedAction}: lease not found (${leaseId})`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const active = ensureLeaseActive(lease, nowIso());
|
|
516
|
+
if (!active.ok) {
|
|
517
|
+
await appendAuthAuditEvent(stateStore, {
|
|
518
|
+
event_type: 'authorization.denied',
|
|
519
|
+
action: normalizedAction,
|
|
520
|
+
actor,
|
|
521
|
+
lease_id: leaseId,
|
|
522
|
+
result: 'deny',
|
|
523
|
+
target: lease.subject || null,
|
|
524
|
+
detail: {
|
|
525
|
+
reason: active.reason
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
throw new Error(`Write authorization denied for ${normalizedAction}: ${active.reason}`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (!hasScopeForAction(lease.scope || [], normalizedAction)) {
|
|
532
|
+
await appendAuthAuditEvent(stateStore, {
|
|
533
|
+
event_type: 'authorization.denied',
|
|
534
|
+
action: normalizedAction,
|
|
535
|
+
actor,
|
|
536
|
+
lease_id: leaseId,
|
|
537
|
+
result: 'deny',
|
|
538
|
+
target: lease.subject || null,
|
|
539
|
+
detail: {
|
|
540
|
+
reason: 'scope_mismatch',
|
|
541
|
+
scope: lease.scope || []
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
throw new Error(`Write authorization denied for ${normalizedAction}: scope mismatch`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
await appendAuthAuditEvent(stateStore, {
|
|
548
|
+
event_type: 'authorization.allowed',
|
|
549
|
+
action: normalizedAction,
|
|
550
|
+
actor,
|
|
551
|
+
lease_id: leaseId,
|
|
552
|
+
result: 'allow',
|
|
553
|
+
target: lease.subject || null,
|
|
554
|
+
detail: {
|
|
555
|
+
scope: lease.scope || [],
|
|
556
|
+
expires_at: lease.expires_at || null
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
required: true,
|
|
562
|
+
passed: true,
|
|
563
|
+
action: normalizedAction,
|
|
564
|
+
lease_id: lease.lease_id,
|
|
565
|
+
lease_subject: lease.subject || null,
|
|
566
|
+
lease_role: lease.role || null,
|
|
567
|
+
lease_scope: Array.isArray(lease.scope) ? [...lease.scope] : [],
|
|
568
|
+
lease_expires_at: lease.expires_at || null,
|
|
569
|
+
policy: sanitizePolicyForOutput(policy),
|
|
570
|
+
policy_path: loadedPolicy.policy_path,
|
|
571
|
+
store_path: stateStore.getStoreRelativePath()
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function getWriteAuthorizationLease(leaseId, dependencies = {}) {
|
|
576
|
+
const normalizedLeaseId = normalizeString(leaseId);
|
|
577
|
+
if (!normalizedLeaseId) {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
582
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
583
|
+
const env = dependencies.env || process.env;
|
|
584
|
+
const stateStore = resolveStore(projectPath, { ...dependencies, fileSystem, env });
|
|
585
|
+
return stateStore.getAuthLease(normalizedLeaseId);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function collectWriteAuthorizationStatus(options = {}, dependencies = {}) {
|
|
589
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
590
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
591
|
+
const env = dependencies.env || process.env;
|
|
592
|
+
const loadedPolicy = await loadWriteAuthorizationPolicy(projectPath, fileSystem, env);
|
|
593
|
+
const stateStore = resolveStore(projectPath, { ...dependencies, fileSystem, env });
|
|
594
|
+
|
|
595
|
+
const activeOnly = options.activeOnly !== false;
|
|
596
|
+
const limit = normalizeInteger(options.limit, 20);
|
|
597
|
+
const eventsLimit = normalizeInteger(options.eventsLimit, 20);
|
|
598
|
+
|
|
599
|
+
const leases = await stateStore.listAuthLeases({
|
|
600
|
+
activeOnly,
|
|
601
|
+
limit
|
|
602
|
+
});
|
|
603
|
+
const events = await stateStore.listAuthEvents({
|
|
604
|
+
limit: eventsLimit
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
if (leases === null || events === null) {
|
|
608
|
+
throw new Error('SQLite state backend unavailable while reading authorization status');
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return {
|
|
612
|
+
success: true,
|
|
613
|
+
policy: sanitizePolicyForOutput(loadedPolicy.policy),
|
|
614
|
+
policy_path: loadedPolicy.policy_path,
|
|
615
|
+
leases,
|
|
616
|
+
events,
|
|
617
|
+
store_path: stateStore.getStoreRelativePath()
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
module.exports = {
|
|
622
|
+
DEFAULT_WRITE_AUTH_POLICY,
|
|
623
|
+
DEFAULT_WRITE_AUTH_POLICY_PATH,
|
|
624
|
+
normalizeWriteAuthPolicy,
|
|
625
|
+
loadWriteAuthorizationPolicy,
|
|
626
|
+
grantWriteAuthorizationLease,
|
|
627
|
+
revokeWriteAuthorizationLease,
|
|
628
|
+
ensureWriteAuthorization,
|
|
629
|
+
collectWriteAuthorizationStatus,
|
|
630
|
+
getWriteAuthorizationLease,
|
|
631
|
+
hasScopeForAction
|
|
632
|
+
};
|