@atlasauth/emulator-core 0.2.0 → 0.3.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/LICENSE +21 -0
- package/dist/custom-html.d.ts +57 -0
- package/dist/custom-html.js +491 -0
- package/dist/custom-html.js.map +1 -0
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +5 -1
- package/dist/engine.js.map +1 -1
- package/dist/error-catalog.js +8 -0
- package/dist/error-catalog.js.map +1 -1
- package/dist/event-gate.js +60 -2
- package/dist/event-gate.js.map +1 -1
- package/dist/generic.js +5 -1
- package/dist/generic.js.map +1 -1
- package/dist/handlers/access-signals.d.ts +48 -0
- package/dist/handlers/access-signals.js +76 -0
- package/dist/handlers/access-signals.js.map +1 -0
- package/dist/handlers/approvals.js +54 -1
- package/dist/handlers/approvals.js.map +1 -1
- package/dist/handlers/authz-data.d.ts +2 -0
- package/dist/handlers/authz-data.js +627 -0
- package/dist/handlers/authz-data.js.map +1 -0
- package/dist/handlers/authz-layers.d.ts +229 -0
- package/dist/handlers/authz-layers.js +786 -0
- package/dist/handlers/authz-layers.js.map +1 -0
- package/dist/handlers/authz-state.d.ts +80 -0
- package/dist/handlers/authz-state.js +175 -0
- package/dist/handlers/authz-state.js.map +1 -0
- package/dist/handlers/authz.js +240 -177
- package/dist/handlers/authz.js.map +1 -1
- package/dist/handlers/billing.d.ts +99 -1
- package/dist/handlers/billing.js +556 -77
- package/dist/handlers/billing.js.map +1 -1
- package/dist/handlers/cedar-ext.d.ts +35 -0
- package/dist/handlers/cedar-ext.js +174 -0
- package/dist/handlers/cedar-ext.js.map +1 -0
- package/dist/handlers/cedar.d.ts +146 -0
- package/dist/handlers/cedar.js +689 -0
- package/dist/handlers/cedar.js.map +1 -0
- package/dist/handlers/connections.d.ts +4 -1
- package/dist/handlers/connections.js +267 -38
- package/dist/handlers/connections.js.map +1 -1
- package/dist/handlers/control.js +6 -1
- package/dist/handlers/control.js.map +1 -1
- package/dist/handlers/device.js +18 -1
- package/dist/handlers/device.js.map +1 -1
- package/dist/handlers/emu3-misc.d.ts +38 -0
- package/dist/handlers/emu3-misc.js +1056 -0
- package/dist/handlers/emu3-misc.js.map +1 -0
- package/dist/handlers/emu4-actions-access.d.ts +46 -0
- package/dist/handlers/emu4-actions-access.js +107 -0
- package/dist/handlers/emu4-actions-access.js.map +1 -0
- package/dist/handlers/emu4-actions-catalog-extra.d.ts +3 -0
- package/dist/handlers/emu4-actions-catalog-extra.js +204 -0
- package/dist/handlers/emu4-actions-catalog-extra.js.map +1 -0
- package/dist/handlers/emu4-actions-catalog.d.ts +3 -0
- package/dist/handlers/emu4-actions-catalog.js +143 -0
- package/dist/handlers/emu4-actions-catalog.js.map +1 -0
- package/dist/handlers/emu4-actions-def.d.ts +50 -0
- package/dist/handlers/emu4-actions-def.js +250 -0
- package/dist/handlers/emu4-actions-def.js.map +1 -0
- package/dist/handlers/emu4-actions-sandbox.d.ts +36 -0
- package/dist/handlers/emu4-actions-sandbox.js +318 -0
- package/dist/handlers/emu4-actions-sandbox.js.map +1 -0
- package/dist/handlers/emu4-actions-typegen.d.ts +5 -0
- package/dist/handlers/emu4-actions-typegen.js +73 -0
- package/dist/handlers/emu4-actions-typegen.js.map +1 -0
- package/dist/handlers/emu4-actions.d.ts +2 -0
- package/dist/handlers/emu4-actions.js +506 -0
- package/dist/handlers/emu4-actions.js.map +1 -0
- package/dist/handlers/emu4-authz-trust.d.ts +40 -0
- package/dist/handlers/emu4-authz-trust.js +488 -0
- package/dist/handlers/emu4-authz-trust.js.map +1 -0
- package/dist/handlers/emu4-authz.d.ts +2 -0
- package/dist/handlers/emu4-authz.js +752 -0
- package/dist/handlers/emu4-authz.js.map +1 -0
- package/dist/handlers/emu4-billing-core.d.ts +23 -0
- package/dist/handlers/emu4-billing-core.js +22 -0
- package/dist/handlers/emu4-billing-core.js.map +1 -0
- package/dist/handlers/emu4-billing-x.d.ts +2 -0
- package/dist/handlers/emu4-billing-x.js +489 -0
- package/dist/handlers/emu4-billing-x.js.map +1 -0
- package/dist/handlers/emu4-billing.d.ts +14 -0
- package/dist/handlers/emu4-billing.js +846 -0
- package/dist/handlers/emu4-billing.js.map +1 -0
- package/dist/handlers/emu4-clones.d.ts +2 -0
- package/dist/handlers/emu4-clones.js +322 -0
- package/dist/handlers/emu4-clones.js.map +1 -0
- package/dist/handlers/emu4-compliance.d.ts +8 -0
- package/dist/handlers/emu4-compliance.js +39 -0
- package/dist/handlers/emu4-compliance.js.map +1 -0
- package/dist/handlers/emu4-config.d.ts +14 -0
- package/dist/handlers/emu4-config.js +148 -0
- package/dist/handlers/emu4-config.js.map +1 -0
- package/dist/handlers/emu4-cookies.d.ts +8 -0
- package/dist/handlers/emu4-cookies.js +248 -0
- package/dist/handlers/emu4-cookies.js.map +1 -0
- package/dist/handlers/emu4-erasure.d.ts +2 -0
- package/dist/handlers/emu4-erasure.js +587 -0
- package/dist/handlers/emu4-erasure.js.map +1 -0
- package/dist/handlers/emu4-federation.d.ts +8 -0
- package/dist/handlers/emu4-federation.js +18 -0
- package/dist/handlers/emu4-federation.js.map +1 -0
- package/dist/handlers/emu4-journey-math.d.ts +42 -0
- package/dist/handlers/emu4-journey-math.js +99 -0
- package/dist/handlers/emu4-journey-math.js.map +1 -0
- package/dist/handlers/emu4-messaging.d.ts +21 -0
- package/dist/handlers/emu4-messaging.js +595 -0
- package/dist/handlers/emu4-messaging.js.map +1 -0
- package/dist/handlers/emu4-minors.d.ts +7 -0
- package/dist/handlers/emu4-minors.js +408 -0
- package/dist/handlers/emu4-minors.js.map +1 -0
- package/dist/handlers/emu4-oauth-audit.d.ts +5 -0
- package/dist/handlers/emu4-oauth-audit.js +17 -0
- package/dist/handlers/emu4-oauth-audit.js.map +1 -0
- package/dist/handlers/emu4-oauth-auth.d.ts +83 -0
- package/dist/handlers/emu4-oauth-auth.js +363 -0
- package/dist/handlers/emu4-oauth-auth.js.map +1 -0
- package/dist/handlers/emu4-oauth-delivery.d.ts +68 -0
- package/dist/handlers/emu4-oauth-delivery.js +207 -0
- package/dist/handlers/emu4-oauth-delivery.js.map +1 -0
- package/dist/handlers/emu4-oauth-keys.d.ts +11 -0
- package/dist/handlers/emu4-oauth-keys.js +268 -0
- package/dist/handlers/emu4-oauth-keys.js.map +1 -0
- package/dist/handlers/emu4-oauth-pub.d.ts +36 -0
- package/dist/handlers/emu4-oauth-pub.js +420 -0
- package/dist/handlers/emu4-oauth-pub.js.map +1 -0
- package/dist/handlers/emu4-oauth.d.ts +10 -0
- package/dist/handlers/emu4-oauth.js +23 -0
- package/dist/handlers/emu4-oauth.js.map +1 -0
- package/dist/handlers/emu4-policies.d.ts +2 -0
- package/dist/handlers/emu4-policies.js +408 -0
- package/dist/handlers/emu4-policies.js.map +1 -0
- package/dist/handlers/emu4-region.d.ts +2 -0
- package/dist/handlers/emu4-region.js +189 -0
- package/dist/handlers/emu4-region.js.map +1 -0
- package/dist/handlers/emu4-saml.d.ts +12 -0
- package/dist/handlers/emu4-saml.js +532 -0
- package/dist/handlers/emu4-saml.js.map +1 -0
- package/dist/handlers/emu4-shared.d.ts +44 -0
- package/dist/handlers/emu4-shared.js +90 -0
- package/dist/handlers/emu4-shared.js.map +1 -0
- package/dist/handlers/emu4-snapshots.d.ts +38 -0
- package/dist/handlers/emu4-snapshots.js +231 -0
- package/dist/handlers/emu4-snapshots.js.map +1 -0
- package/dist/handlers/emu4-sns.d.ts +9 -0
- package/dist/handlers/emu4-sns.js +21 -0
- package/dist/handlers/emu4-sns.js.map +1 -0
- package/dist/handlers/emu4-trust-binding.d.ts +27 -0
- package/dist/handlers/emu4-trust-binding.js +344 -0
- package/dist/handlers/emu4-trust-binding.js.map +1 -0
- package/dist/handlers/emu4-trust.d.ts +35 -0
- package/dist/handlers/emu4-trust.js +543 -0
- package/dist/handlers/emu4-trust.js.map +1 -0
- package/dist/handlers/fga-engine.d.ts +78 -0
- package/dist/handlers/fga-engine.js +663 -0
- package/dist/handlers/fga-engine.js.map +1 -0
- package/dist/handlers/governance.js +3 -2
- package/dist/handlers/governance.js.map +1 -1
- package/dist/handlers/hooks.js +13 -0
- package/dist/handlers/hooks.js.map +1 -1
- package/dist/handlers/index.js +14 -1
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/machines.js +6 -0
- package/dist/handlers/machines.js.map +1 -1
- package/dist/handlers/messaging.d.ts +3 -1
- package/dist/handlers/messaging.js +455 -48
- package/dist/handlers/messaging.js.map +1 -1
- package/dist/handlers/oauth-adv.d.ts +191 -0
- package/dist/handlers/oauth-adv.js +1305 -0
- package/dist/handlers/oauth-adv.js.map +1 -0
- package/dist/handlers/oauth-flows.d.ts +25 -1
- package/dist/handlers/oauth-flows.js +111 -50
- package/dist/handlers/oauth-flows.js.map +1 -1
- package/dist/handlers/oauth.d.ts +24 -3
- package/dist/handlers/oauth.js +245 -60
- package/dist/handlers/oauth.js.map +1 -1
- package/dist/handlers/orgs.js +6 -0
- package/dist/handlers/orgs.js.map +1 -1
- package/dist/handlers/platform.d.ts +5 -0
- package/dist/handlers/platform.js +33 -15
- package/dist/handlers/platform.js.map +1 -1
- package/dist/handlers/scim.d.ts +2 -0
- package/dist/handlers/scim.js +198 -11
- package/dist/handlers/scim.js.map +1 -1
- package/dist/handlers/sessions.d.ts +2 -0
- package/dist/handlers/sessions.js +24 -16
- package/dist/handlers/sessions.js.map +1 -1
- package/dist/handlers/users.js +3 -0
- package/dist/handlers/users.js.map +1 -1
- package/dist/jwe.d.ts +18 -0
- package/dist/jwe.js +101 -0
- package/dist/jwe.js.map +1 -0
- package/dist/jws.js +2 -0
- package/dist/jws.js.map +1 -1
- package/dist/messaging-callbacks.d.ts +100 -0
- package/dist/messaging-callbacks.js +767 -0
- package/dist/messaging-callbacks.js.map +1 -0
- package/dist/openapi.d.ts +1 -0
- package/dist/openapi.js +44 -2
- package/dist/openapi.js.map +1 -1
- package/dist/parity-emu3-authz.d.ts +3 -0
- package/dist/parity-emu3-authz.js +61 -0
- package/dist/parity-emu3-authz.js.map +1 -0
- package/dist/parity-emu3-billing.d.ts +3 -0
- package/dist/parity-emu3-billing.js +25 -0
- package/dist/parity-emu3-billing.js.map +1 -0
- package/dist/parity-emu3-messaging.d.ts +3 -0
- package/dist/parity-emu3-messaging.js +27 -0
- package/dist/parity-emu3-messaging.js.map +1 -0
- package/dist/parity-emu3-misc.d.ts +2 -0
- package/dist/parity-emu3-misc.js +63 -0
- package/dist/parity-emu3-misc.js.map +1 -0
- package/dist/parity-emu3-oauth.d.ts +2 -0
- package/dist/parity-emu3-oauth.js +39 -0
- package/dist/parity-emu3-oauth.js.map +1 -0
- package/dist/parity-emu4-actions.d.ts +2 -0
- package/dist/parity-emu4-actions.js +38 -0
- package/dist/parity-emu4-actions.js.map +1 -0
- package/dist/parity-emu4-authz.d.ts +3 -0
- package/dist/parity-emu4-authz.js +68 -0
- package/dist/parity-emu4-authz.js.map +1 -0
- package/dist/parity-emu4-billing.d.ts +6 -0
- package/dist/parity-emu4-billing.js +44 -0
- package/dist/parity-emu4-billing.js.map +1 -0
- package/dist/parity-emu4-compliance.d.ts +2 -0
- package/dist/parity-emu4-compliance.js +92 -0
- package/dist/parity-emu4-compliance.js.map +1 -0
- package/dist/parity-emu4-federation.d.ts +2 -0
- package/dist/parity-emu4-federation.js +33 -0
- package/dist/parity-emu4-federation.js.map +1 -0
- package/dist/parity-emu4-messaging.d.ts +3 -0
- package/dist/parity-emu4-messaging.js +42 -0
- package/dist/parity-emu4-messaging.js.map +1 -0
- package/dist/parity-emu4-oauth.d.ts +2 -0
- package/dist/parity-emu4-oauth.js +55 -0
- package/dist/parity-emu4-oauth.js.map +1 -0
- package/dist/parity-emu4-trust.d.ts +2 -0
- package/dist/parity-emu4-trust.js +41 -0
- package/dist/parity-emu4-trust.js.map +1 -0
- package/dist/parity-routes.js +19 -1
- package/dist/parity-routes.js.map +1 -1
- package/dist/sns-cert.d.ts +9 -0
- package/dist/sns-cert.js +101 -0
- package/dist/sns-cert.js.map +1 -0
- package/dist/tokens.d.ts +69 -4
- package/dist/tokens.js +164 -38
- package/dist/tokens.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.emu4AuthzHandlers = emu4AuthzHandlers;
|
|
4
|
+
const errors_1 = require("../errors");
|
|
5
|
+
const access_signals_1 = require("./access-signals");
|
|
6
|
+
const cedar_1 = require("./cedar");
|
|
7
|
+
const authz_layers_1 = require("./authz-layers");
|
|
8
|
+
const authz_state_1 = require("./authz-state");
|
|
9
|
+
const emu4_authz_trust_1 = require("./emu4-authz-trust");
|
|
10
|
+
const fga_engine_1 = require("./fga-engine");
|
|
11
|
+
/**
|
|
12
|
+
* Round-four authorization routes: FGA models with rewrites and the batch-check, list-objects, list-users and expand queries
|
|
13
|
+
* over the stored tuples; Cedar policy templates and the links that instantiate them; access-policy simulation and network
|
|
14
|
+
* zone checks that read geography, risk and device signals; and the agent delegation graph (see emu4-authz-trust.ts).
|
|
15
|
+
*/
|
|
16
|
+
const bad = (message, param) => new errors_1.EmulatorError('VALIDATION_FAILED', message, param);
|
|
17
|
+
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
18
|
+
// ── FGA ──
|
|
19
|
+
const modelTable = (e) => (0, authz_state_1.genericTable)(e, 'fga/stores/authorization-models', 'fgm');
|
|
20
|
+
const modelView = (m, withModel) => ({ object: 'fga.authorization_model', id: m.id, store_id: m._parent ?? m.store_id, schema_version: m.schema_version ?? '1.1', ...(withModel ? { model: m.model } : {}), created_at: m.created_at });
|
|
21
|
+
const storeOf = (e, id) => {
|
|
22
|
+
if (!id || !(0, authz_state_1.storeExists)(e, id))
|
|
23
|
+
throw new errors_1.EmulatorError('NOT_FOUND', 'Unknown FGA store.');
|
|
24
|
+
return id;
|
|
25
|
+
};
|
|
26
|
+
/** The model a query runs against: the one named, or the store's newest. A store with no model has no rules to resolve against. */
|
|
27
|
+
function resolveModel(e, storeId, modelId) {
|
|
28
|
+
let row;
|
|
29
|
+
if (typeof modelId === 'string' && modelId) {
|
|
30
|
+
row = modelTable(e).find((m) => m._parent === storeId && m.id === modelId);
|
|
31
|
+
if (!row)
|
|
32
|
+
throw new errors_1.EmulatorError('NOT_FOUND', 'Unknown authorization model for this store.');
|
|
33
|
+
}
|
|
34
|
+
else
|
|
35
|
+
row = (0, authz_layers_1.latestModel)(e, storeId);
|
|
36
|
+
if (!row)
|
|
37
|
+
throw bad('This store has no authorization model yet; write one before running a query.', 'authorization_model_id');
|
|
38
|
+
try {
|
|
39
|
+
const compiled = (0, fga_engine_1.compileStored)(row.model) ?? (typeof row.model === 'string' ? (0, fga_engine_1.compileModel)((0, fga_engine_1.dslToModel)(row.model)) : null);
|
|
40
|
+
if (!compiled)
|
|
41
|
+
throw new fga_engine_1.ModelError(['The model could not be read.']);
|
|
42
|
+
return { row, compiled };
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
throw bad(err instanceof fga_engine_1.ModelError ? err.errors.join(' ') : 'The authorization model could not be read.', 'authorization_model_id');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Contextual tuples: a list of tuple keys, or `{ tuple_keys: [...] }`. */
|
|
49
|
+
function contextualOf(raw, field) {
|
|
50
|
+
const list = raw && typeof raw === 'object' && !Array.isArray(raw) && 'tuple_keys' in raw ? raw.tuple_keys : raw;
|
|
51
|
+
if (list === undefined || list === null)
|
|
52
|
+
return [];
|
|
53
|
+
if (!Array.isArray(list))
|
|
54
|
+
throw bad(`${field} must be an array of tuple keys.`, field);
|
|
55
|
+
if (list.length > 20)
|
|
56
|
+
throw bad(`${field} accepts at most 20 tuples.`, field);
|
|
57
|
+
return list.map((t, i) => {
|
|
58
|
+
if (!t || typeof t.user !== 'string' || typeof t.relation !== 'string' || typeof t.object !== 'string')
|
|
59
|
+
throw bad(`${field}[${i}] needs string user, relation and object.`, field);
|
|
60
|
+
try {
|
|
61
|
+
(0, fga_engine_1.parseUser)(t.user);
|
|
62
|
+
(0, fga_engine_1.parseObject)(t.object);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
throw bad(err instanceof Error ? err.message : 'Malformed tuple.', field);
|
|
66
|
+
}
|
|
67
|
+
const c = obj(t.condition);
|
|
68
|
+
return { user: t.user, relation: t.relation, object: t.object, ...(typeof c.name === 'string' ? { condition: { name: c.name, context: obj(c.context) } } : {}) };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const tupleList = (e, storeId, extra) => [...(0, authz_state_1.storeTuples)(e, storeId).map((t) => ({ user: t.user, relation: t.relation, object: t.object, ...(t.condition ? { condition: t.condition } : {}) })), ...extra];
|
|
72
|
+
const resolver = (e, storeId, compiled, extra, context) => {
|
|
73
|
+
try {
|
|
74
|
+
return new fga_engine_1.Resolver(compiled, tupleList(e, storeId, extra), context && typeof context === 'object' && !Array.isArray(context) ? context : undefined);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (err instanceof fga_engine_1.TupleError)
|
|
78
|
+
throw bad(err.message);
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const token = (e, storeId) => `${storeId}:${(0, authz_state_1.st)(e).revisions.get(storeId) ?? 0}`;
|
|
83
|
+
const strings = (b, fields, what) => {
|
|
84
|
+
for (const f of fields)
|
|
85
|
+
if (typeof b[f] !== 'string' || !b[f])
|
|
86
|
+
throw bad(`${what} requires string ${fields.join(', ')}.`, fields[fields.length - 1] === 'type' ? 'type' : f);
|
|
87
|
+
};
|
|
88
|
+
function logCheck(e, storeId, modelId, r) {
|
|
89
|
+
(0, authz_layers_1.logDecision)(e, { source: 'fga', decision: r.allowed ? 'allow' : 'deny', principal: r.user, action: r.relation, resource: r.object, input: { user: r.user, relation: r.relation, object: r.object, contextual_tuples: r.contextual, context: r.context ?? null }, result: { authorization_model_id: modelId }, policyVersion: { store_id: storeId, authorization_model_id: modelId } });
|
|
90
|
+
}
|
|
91
|
+
const checkOne = (rs, user, relation, object) => {
|
|
92
|
+
try {
|
|
93
|
+
return rs.check((0, fga_engine_1.parseUser)(user), object, relation);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
if (err instanceof fga_engine_1.TupleError)
|
|
97
|
+
throw bad(err.message, 'user');
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const pageAfter = (items, after, limit) => {
|
|
102
|
+
const sorted = [...items].sort();
|
|
103
|
+
const start = typeof after === 'string' && after ? sorted.findIndex((t) => t > after) : 0;
|
|
104
|
+
const slice = start < 0 ? [] : sorted.slice(start, start + limit);
|
|
105
|
+
const more = start >= 0 && start + limit < sorted.length;
|
|
106
|
+
return { data: slice, has_more: more, next_cursor: more && slice.length ? slice[slice.length - 1] : null };
|
|
107
|
+
};
|
|
108
|
+
function fgaHandlers() {
|
|
109
|
+
const base = '/v1/fga/stores/:id';
|
|
110
|
+
return [
|
|
111
|
+
{
|
|
112
|
+
method: 'GET',
|
|
113
|
+
pattern: `${base}/authorization-models`,
|
|
114
|
+
scope: 'fga:read',
|
|
115
|
+
handler: (c) => {
|
|
116
|
+
const storeId = storeOf(c.engine, c.params.id);
|
|
117
|
+
return { body: { object: 'list', data: modelTable(c.engine).filter((m) => m._parent === storeId).map((m) => modelView(m, false)), has_more: false } };
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
method: 'POST',
|
|
122
|
+
pattern: `${base}/authorization-models`,
|
|
123
|
+
scope: 'fga:write',
|
|
124
|
+
handler: (c) => {
|
|
125
|
+
const e = c.engine;
|
|
126
|
+
const storeId = storeOf(e, c.params.id);
|
|
127
|
+
const b = c.body;
|
|
128
|
+
if (Array.isArray(b.modules) && b.modules.length)
|
|
129
|
+
throw bad('Modular models are not supported by the emulator: send one model as `dsl` or `type_definitions`.', 'modules');
|
|
130
|
+
let model;
|
|
131
|
+
if (typeof b.dsl === 'string' && b.dsl.trim()) {
|
|
132
|
+
try {
|
|
133
|
+
model = (0, fga_engine_1.dslToModel)(b.dsl);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
throw bad(`Could not parse the FGA DSL: ${err instanceof Error ? err.message : 'invalid model'}`, 'dsl');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else
|
|
140
|
+
model = { type_definitions: b.type_definitions, schema_version: typeof b.schema_version === 'string' && b.schema_version ? b.schema_version : '1.1', ...(b.conditions ? { conditions: b.conditions } : {}) };
|
|
141
|
+
try {
|
|
142
|
+
(0, fga_engine_1.compileModel)(model);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
if (err instanceof fga_engine_1.ModelError)
|
|
146
|
+
throw bad(err.errors.join(' '), 'type_definitions');
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
const row = modelTable(e).insert({ object: 'fga.authorization_model', store_id: storeId, schema_version: String(model.schema_version ?? '1.1'), model, created_at: e.clock.now(), _parent: storeId });
|
|
150
|
+
(0, authz_state_1.pushEvent)(e, 'model_written', storeId, 'api', { authorization_model_id: row.id });
|
|
151
|
+
(0, emu4_authz_trust_1.auditAuthz)(e, 'fga_authorization_model.created', 'fga_authorization_model', row.id, { store_id: storeId, schema_version: row.schema_version });
|
|
152
|
+
return { status: 201, body: modelView(row, true) };
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
method: 'GET',
|
|
157
|
+
pattern: `${base}/authorization-models/:mid`,
|
|
158
|
+
scope: 'fga:read',
|
|
159
|
+
handler: (c) => {
|
|
160
|
+
const storeId = storeOf(c.engine, c.params.id);
|
|
161
|
+
const row = modelTable(c.engine).find((m) => m._parent === storeId && m.id === c.params.mid);
|
|
162
|
+
if (!row)
|
|
163
|
+
throw new errors_1.EmulatorError('NOT_FOUND', 'Unknown authorization model.');
|
|
164
|
+
return { body: modelView(row, true) };
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
method: 'POST',
|
|
169
|
+
pattern: `${base}/batch-check`,
|
|
170
|
+
scope: 'fga:read',
|
|
171
|
+
handler: (c) => {
|
|
172
|
+
const e = c.engine;
|
|
173
|
+
const storeId = storeOf(e, c.params.id);
|
|
174
|
+
const b = c.body;
|
|
175
|
+
if (!Array.isArray(b.checks) || !b.checks.length)
|
|
176
|
+
throw bad('Provide a non-empty checks array.', 'checks');
|
|
177
|
+
if (b.checks.length > 100)
|
|
178
|
+
throw bad('A batch-check accepts at most 100 checks.', 'checks');
|
|
179
|
+
const items = b.checks.map((raw, i) => {
|
|
180
|
+
const k = obj(raw);
|
|
181
|
+
if (typeof k.user !== 'string' || typeof k.relation !== 'string' || typeof k.object !== 'string')
|
|
182
|
+
throw bad(`check ${i} requires string user, relation and object.`, 'checks');
|
|
183
|
+
return { correlation_id: typeof k.correlation_id === 'string' ? k.correlation_id : String(i), user: k.user, relation: k.relation, object: k.object, contextual: contextualOf(k.contextual_tuples, 'checks'), context: k.context };
|
|
184
|
+
});
|
|
185
|
+
const { row, compiled } = resolveModel(e, storeId, b.authorization_model_id);
|
|
186
|
+
const result = items.map((it) => {
|
|
187
|
+
const allowed = checkOne(resolver(e, storeId, compiled, it.contextual, it.context), it.user, it.relation, it.object);
|
|
188
|
+
logCheck(e, storeId, row.id, { ...it, allowed });
|
|
189
|
+
return { correlation_id: it.correlation_id, allowed };
|
|
190
|
+
});
|
|
191
|
+
return { body: { object: 'fga.batch_check', authorization_model_id: row.id, consistency_token: token(e, storeId), result } };
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
method: 'POST',
|
|
196
|
+
pattern: `${base}/list-objects`,
|
|
197
|
+
scope: 'fga:read',
|
|
198
|
+
handler: (c) => {
|
|
199
|
+
const e = c.engine;
|
|
200
|
+
const storeId = storeOf(e, c.params.id);
|
|
201
|
+
const b = c.body;
|
|
202
|
+
if (typeof b.user !== 'string' || typeof b.relation !== 'string' || typeof b.type !== 'string')
|
|
203
|
+
throw bad('list-objects requires string user, relation and type.', 'type');
|
|
204
|
+
const { row, compiled } = resolveModel(e, storeId, b.authorization_model_id);
|
|
205
|
+
let ids;
|
|
206
|
+
try {
|
|
207
|
+
ids = resolver(e, storeId, compiled, contextualOf(b.contextual_tuples, 'contextual_tuples'), b.context).listObjects((0, fga_engine_1.parseUser)(b.user), b.relation, b.type);
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
if (err instanceof fga_engine_1.TupleError)
|
|
211
|
+
throw bad(err.message, 'user');
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
const all = ids.map((id) => `${b.type}:${id}`);
|
|
215
|
+
// Without a limit every object comes back; with one the list is sorted and paged.
|
|
216
|
+
const limit = Math.min(Math.max(Number.isInteger(b.limit) ? b.limit : 100, 1), 1000);
|
|
217
|
+
const page = b.limit === undefined ? { data: all, has_more: false, next_cursor: null } : pageAfter(all, b.starting_after, limit);
|
|
218
|
+
return { body: { object: 'fga.list_objects', objects: page.data, has_more: page.has_more, next_cursor: page.next_cursor, authorization_model_id: row.id, consistency_token: token(e, storeId) } };
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
method: 'POST',
|
|
223
|
+
pattern: `${base}/expand`,
|
|
224
|
+
scope: 'fga:read',
|
|
225
|
+
handler: (c) => {
|
|
226
|
+
const e = c.engine;
|
|
227
|
+
const storeId = storeOf(e, c.params.id);
|
|
228
|
+
strings(c.body, ['object', 'relation'], 'expand');
|
|
229
|
+
const { row, compiled } = resolveModel(e, storeId, c.body.authorization_model_id);
|
|
230
|
+
try {
|
|
231
|
+
return { body: { object: 'fga.expand', tree: resolver(e, storeId, compiled, [], undefined).expand(c.body.object, c.body.relation), authorization_model_id: row.id } };
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
if (err instanceof fga_engine_1.TupleError)
|
|
235
|
+
throw bad(err.message, 'object');
|
|
236
|
+
throw err;
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
method: 'POST',
|
|
242
|
+
pattern: `${base}/list-users`,
|
|
243
|
+
scope: 'fga:read',
|
|
244
|
+
handler: (c) => {
|
|
245
|
+
const e = c.engine;
|
|
246
|
+
const storeId = storeOf(e, c.params.id);
|
|
247
|
+
const b = c.body;
|
|
248
|
+
if (typeof b.relation !== 'string' || !b.relation)
|
|
249
|
+
throw bad('list-users requires a relation.', 'relation');
|
|
250
|
+
const o = typeof b.object === 'string' ? b.object : obj(b.object).type && obj(b.object).id ? `${obj(b.object).type}:${obj(b.object).id}` : '';
|
|
251
|
+
if (!o)
|
|
252
|
+
throw bad('list-users requires an object.', 'object');
|
|
253
|
+
const filters = Array.isArray(b.user_filters) ? b.user_filters : [];
|
|
254
|
+
if (!filters.length || filters.some((f) => typeof f?.type !== 'string'))
|
|
255
|
+
throw bad('user_filters needs at least one { type }.', 'user_filters');
|
|
256
|
+
if (filters.some((f) => f.relation))
|
|
257
|
+
throw bad('The emulator lists concrete users only: a user filter with a relation is not supported.', 'user_filters');
|
|
258
|
+
const { row, compiled } = resolveModel(e, storeId, b.authorization_model_id);
|
|
259
|
+
let users;
|
|
260
|
+
try {
|
|
261
|
+
const rs = resolver(e, storeId, compiled, contextualOf(b.contextual_tuples, 'contextual_tuples'), b.context);
|
|
262
|
+
users = [...new Set(filters.flatMap((f) => rs.listUsers(o, b.relation, f.type).map((id) => `${f.type}:${id}`)))];
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
if (err instanceof fga_engine_1.TupleError)
|
|
266
|
+
throw bad(err.message, 'object');
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
const size = Math.min(Math.max(Number.isInteger(b.page_size) ? b.page_size : 1000, 1), 1000);
|
|
270
|
+
const offset = Math.max(Number(b.continuation_token) || 0, 0);
|
|
271
|
+
return { body: { object: 'fga.list_users', users: users.slice(offset, offset + size), excluded_users: [], continuation_token: offset + size < users.length ? String(offset + size) : null, authorization_model_id: row.id, consistency_token: token(e, storeId) } };
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
}
|
|
276
|
+
// ── Cedar templates and links ──
|
|
277
|
+
/** Replace the slots in template source (outside string literals) with entity literals. */
|
|
278
|
+
function substituteSlots(source, values) {
|
|
279
|
+
const lit = (x) => (x ? `${x.type}::"${String(x.id).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : null);
|
|
280
|
+
let out = '';
|
|
281
|
+
for (let i = 0; i < source.length;) {
|
|
282
|
+
const ch = source[i];
|
|
283
|
+
if (ch === '"') {
|
|
284
|
+
let j = i + 1;
|
|
285
|
+
while (j < source.length && source[j] !== '"')
|
|
286
|
+
j += source[j] === '\\' ? 2 : 1;
|
|
287
|
+
out += source.slice(i, j + 1);
|
|
288
|
+
i = j + 1;
|
|
289
|
+
}
|
|
290
|
+
else if (ch === '/' && source[i + 1] === '/') {
|
|
291
|
+
const j = source.indexOf('\n', i);
|
|
292
|
+
const end = j < 0 ? source.length : j;
|
|
293
|
+
out += source.slice(i, end);
|
|
294
|
+
i = end;
|
|
295
|
+
}
|
|
296
|
+
else if (ch === '?' && /^\?(principal|resource)\b/.test(source.slice(i))) {
|
|
297
|
+
const name = source.slice(i + 1).startsWith('principal') ? 'principal' : 'resource';
|
|
298
|
+
out += lit(values[name]) ?? `?${name}`;
|
|
299
|
+
i += 1 + name.length;
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
out += ch;
|
|
303
|
+
i++;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
const templates = (e) => (0, authz_state_1.genericTable)(e, 'abac_templates', 'abt');
|
|
309
|
+
const links = (e) => (0, authz_state_1.genericTable)(e, 'abac_policy_links', 'abl');
|
|
310
|
+
const nameOf = (v) => {
|
|
311
|
+
if (typeof v !== 'string' || !v.trim() || v.length > 100)
|
|
312
|
+
throw bad('name is required (1-100 characters).', 'name');
|
|
313
|
+
return v.trim();
|
|
314
|
+
};
|
|
315
|
+
function parseTemplate(source) {
|
|
316
|
+
if (typeof source !== 'string' || !source.trim())
|
|
317
|
+
throw bad('source is required.', 'source');
|
|
318
|
+
if (source.length > 100_000)
|
|
319
|
+
throw bad('source is too large (100,000 characters at most).', 'source');
|
|
320
|
+
try {
|
|
321
|
+
const parsed = (0, cedar_1.parseCedarTemplate)(source);
|
|
322
|
+
if (!parsed.length)
|
|
323
|
+
throw bad('A template needs at least one policy.', 'source');
|
|
324
|
+
return parsed;
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
if (err instanceof cedar_1.CedarParseError)
|
|
328
|
+
throw bad(`${err.message} (at character ${err.position})`, 'source');
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const entityRef = (raw, field) => {
|
|
333
|
+
if (raw === undefined)
|
|
334
|
+
return undefined;
|
|
335
|
+
if (raw === null)
|
|
336
|
+
return null;
|
|
337
|
+
const o = obj(raw);
|
|
338
|
+
if (typeof o.type !== 'string' || !o.type || typeof o.id !== 'string' || !o.id)
|
|
339
|
+
throw bad(`${field} needs a type and an id.`, field);
|
|
340
|
+
return { type: o.type, id: o.id };
|
|
341
|
+
};
|
|
342
|
+
/** Fill a template for one link; a missing or surplus slot value is a 400 naming it. */
|
|
343
|
+
function instantiate(source, principal, resource) {
|
|
344
|
+
const parsed = (0, cedar_1.parseCedarTemplate)(source);
|
|
345
|
+
const slots = (0, cedar_1.cedarTemplateSlots)(parsed);
|
|
346
|
+
for (const [name, v] of [['principal', principal], ['resource', resource]])
|
|
347
|
+
if (v && !slots.includes(`?${name}`))
|
|
348
|
+
throw bad(`The template has no ?${name} slot, so a ${name} cannot be set.`, name);
|
|
349
|
+
try {
|
|
350
|
+
return (0, cedar_1.linkCedarTemplate)(parsed, { principal: (principal ?? undefined), resource: (resource ?? undefined) });
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
if (err instanceof cedar_1.CedarLinkError)
|
|
354
|
+
throw bad(err.message, slots.includes('?principal') && !principal ? 'principal' : 'resource');
|
|
355
|
+
throw err;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const parseStore = (e, v) => {
|
|
359
|
+
if (v === undefined)
|
|
360
|
+
return undefined;
|
|
361
|
+
if (v === null)
|
|
362
|
+
return null;
|
|
363
|
+
if (typeof v !== 'string' || !(0, authz_state_1.storeExists)(e, v))
|
|
364
|
+
throw bad('Unknown FGA store.', 'fga_store_id');
|
|
365
|
+
return v;
|
|
366
|
+
};
|
|
367
|
+
const templateView = (e, t) => ({
|
|
368
|
+
object: 'abac_policy_template',
|
|
369
|
+
id: t.id,
|
|
370
|
+
name: t.name,
|
|
371
|
+
description: t.description ?? null,
|
|
372
|
+
source: t.source,
|
|
373
|
+
version: t.version,
|
|
374
|
+
slots: (0, cedar_1.cedarTemplateSlots)((0, cedar_1.parseCedarTemplate)(t.source)),
|
|
375
|
+
fga_store_id: t.fga_store_id ?? null,
|
|
376
|
+
link_count: links(e).filter((l) => l.template_id === t.id).length,
|
|
377
|
+
created_at: t.created_at,
|
|
378
|
+
updated_at: t.updated_at,
|
|
379
|
+
});
|
|
380
|
+
const linkView = (e, l) => {
|
|
381
|
+
const t = templates(e).get(l.template_id);
|
|
382
|
+
return { object: 'abac_policy_link', id: l.id, name: l.name, template_id: l.template_id, template_name: t?.name ?? null, template_version: t?.version ?? null, principal: l.principal ?? null, resource: l.resource ?? null, enabled: l.enabled, source: t ? substituteSlots(t.source, { principal: l.principal, resource: l.resource }) : '', created_at: l.created_at, updated_at: l.updated_at };
|
|
383
|
+
};
|
|
384
|
+
const needTemplate = (e, id) => {
|
|
385
|
+
const t = templates(e).get(id);
|
|
386
|
+
if (!t)
|
|
387
|
+
throw new errors_1.EmulatorError('NOT_FOUND', 'Unknown ABAC policy template.');
|
|
388
|
+
return t;
|
|
389
|
+
};
|
|
390
|
+
const needLink = (e, id) => {
|
|
391
|
+
const l = links(e).get(id);
|
|
392
|
+
if (!l)
|
|
393
|
+
throw new errors_1.EmulatorError('NOT_FOUND', 'Unknown ABAC policy link.');
|
|
394
|
+
return l;
|
|
395
|
+
};
|
|
396
|
+
function templateHandlers() {
|
|
397
|
+
return [
|
|
398
|
+
{ method: 'GET', pattern: '/v1/abac_templates', scope: 'abac:read', handler: (c) => ({ body: { object: 'list', data: templates(c.engine).all().map((t) => templateView(c.engine, t)), has_more: false } }) },
|
|
399
|
+
{
|
|
400
|
+
method: 'POST',
|
|
401
|
+
pattern: '/v1/abac_templates',
|
|
402
|
+
scope: 'abac:write',
|
|
403
|
+
handler: (c) => {
|
|
404
|
+
const e = c.engine;
|
|
405
|
+
const name = nameOf(c.body.name);
|
|
406
|
+
if (templates(e).find((t) => t.name === name))
|
|
407
|
+
throw bad(`An ABAC policy template named "${name}" already exists.`, 'name');
|
|
408
|
+
parseTemplate(c.body.source);
|
|
409
|
+
const now = e.clock.now();
|
|
410
|
+
const row = templates(e).insert({ object: 'abac_policy_template', name, description: typeof c.body.description === 'string' ? c.body.description : null, source: c.body.source, version: 1, fga_store_id: parseStore(e, c.body.fga_store_id) ?? null, created_at: now, updated_at: now });
|
|
411
|
+
(0, emu4_authz_trust_1.auditAuthz)(e, 'abac_template.created', 'abac_template', row.id, { name });
|
|
412
|
+
return { status: 201, body: templateView(e, row) };
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
{ method: 'GET', pattern: '/v1/abac_templates/:id', scope: 'abac:read', handler: (c) => ({ body: templateView(c.engine, needTemplate(c.engine, c.params.id)) }) },
|
|
416
|
+
{
|
|
417
|
+
method: 'PATCH',
|
|
418
|
+
pattern: '/v1/abac_templates/:id',
|
|
419
|
+
scope: 'abac:write',
|
|
420
|
+
handler: (c) => {
|
|
421
|
+
const e = c.engine;
|
|
422
|
+
const t = needTemplate(e, c.params.id);
|
|
423
|
+
const b = c.body;
|
|
424
|
+
const patch = { updated_at: e.clock.now() };
|
|
425
|
+
if (b.name !== undefined) {
|
|
426
|
+
const name = nameOf(b.name);
|
|
427
|
+
const clash = templates(e).find((x) => x.name === name);
|
|
428
|
+
if (clash && clash.id !== t.id)
|
|
429
|
+
throw bad(`An ABAC policy template named "${name}" already exists.`, 'name');
|
|
430
|
+
patch.name = name;
|
|
431
|
+
}
|
|
432
|
+
if (b.description !== undefined)
|
|
433
|
+
patch.description = typeof b.description === 'string' ? b.description : null;
|
|
434
|
+
const store = parseStore(e, b.fga_store_id);
|
|
435
|
+
if (store !== undefined)
|
|
436
|
+
patch.fga_store_id = store;
|
|
437
|
+
let affected = 0;
|
|
438
|
+
if (b.source !== undefined) {
|
|
439
|
+
parseTemplate(b.source);
|
|
440
|
+
// A new source becomes the source of every link at once; refused if a link could no longer be filled in.
|
|
441
|
+
const problems = [];
|
|
442
|
+
for (const l of links(e).filter((x) => x.template_id === t.id)) {
|
|
443
|
+
try {
|
|
444
|
+
instantiate(b.source, l.principal ?? null, l.resource ?? null);
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
problems.push(`link "${l.name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (problems.length)
|
|
451
|
+
throw bad(problems.join(' '), 'source');
|
|
452
|
+
affected = links(e).filter((x) => x.template_id === t.id).length;
|
|
453
|
+
if (b.source !== t.source)
|
|
454
|
+
patch.version = (t.version ?? 1) + 1;
|
|
455
|
+
patch.source = b.source;
|
|
456
|
+
}
|
|
457
|
+
const row = templates(e).update(t.id, patch);
|
|
458
|
+
(0, emu4_authz_trust_1.auditAuthz)(e, 'abac_template.updated', 'abac_template', row.id, { name: row.name, version: row.version, links_updated: affected });
|
|
459
|
+
return { body: { ...templateView(e, row), links_updated: affected } };
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
method: 'DELETE',
|
|
464
|
+
pattern: '/v1/abac_templates/:id',
|
|
465
|
+
scope: 'abac:write',
|
|
466
|
+
handler: (c) => {
|
|
467
|
+
const e = c.engine;
|
|
468
|
+
const t = needTemplate(e, c.params.id);
|
|
469
|
+
const mine = links(e).filter((l) => l.template_id === t.id);
|
|
470
|
+
if (mine.length && c.query.get('force') !== 'true')
|
|
471
|
+
throw new errors_1.EmulatorError('CONFLICT', `The template has ${mine.length} link${mine.length === 1 ? '' : 's'}; deleting it deletes them too. Pass force=true to delete both.`, undefined, { meta: { link_count: mine.length } });
|
|
472
|
+
for (const l of mine)
|
|
473
|
+
links(e).delete(l.id);
|
|
474
|
+
templates(e).delete(t.id);
|
|
475
|
+
(0, emu4_authz_trust_1.auditAuthz)(e, 'abac_template.deleted', 'abac_template', t.id, { name: t.name, links: mine.length });
|
|
476
|
+
return { body: { object: 'abac_policy_template', id: t.id, deleted: true, links_deleted: mine.length } };
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
method: 'GET',
|
|
481
|
+
pattern: '/v1/abac_policy_links',
|
|
482
|
+
scope: 'abac:read',
|
|
483
|
+
handler: (c) => ({ body: { object: 'list', data: links(c.engine).all().filter((l) => !c.query.get('template_id') || l.template_id === c.query.get('template_id')).map((l) => linkView(c.engine, l)), has_more: false } }),
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
method: 'POST',
|
|
487
|
+
pattern: '/v1/abac_policy_links',
|
|
488
|
+
scope: 'abac:write',
|
|
489
|
+
handler: (c) => {
|
|
490
|
+
const e = c.engine;
|
|
491
|
+
const b = c.body;
|
|
492
|
+
if (typeof b.template_id !== 'string')
|
|
493
|
+
throw bad('template_id is required.', 'template_id');
|
|
494
|
+
const t = templates(e).get(b.template_id);
|
|
495
|
+
if (!t)
|
|
496
|
+
throw bad('Unknown ABAC policy template.', 'template_id');
|
|
497
|
+
const name = nameOf(b.name);
|
|
498
|
+
if (links(e).find((l) => l.name === name))
|
|
499
|
+
throw bad(`An ABAC policy link named "${name}" already exists.`, 'name');
|
|
500
|
+
const principal = entityRef(b.principal, 'principal') ?? null;
|
|
501
|
+
const resource = entityRef(b.resource, 'resource') ?? null;
|
|
502
|
+
instantiate(t.source, principal, resource);
|
|
503
|
+
const now = e.clock.now();
|
|
504
|
+
const row = links(e).insert({ object: 'abac_policy_link', template_id: t.id, name, principal, resource, enabled: b.enabled === undefined ? true : Boolean(b.enabled), created_at: now, updated_at: now });
|
|
505
|
+
(0, emu4_authz_trust_1.auditAuthz)(e, 'abac_link.created', 'abac_link', row.id, { name, template: t.name });
|
|
506
|
+
return { status: 201, body: linkView(e, row) };
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
{ method: 'GET', pattern: '/v1/abac_policy_links/:id', scope: 'abac:read', handler: (c) => ({ body: linkView(c.engine, needLink(c.engine, c.params.id)) }) },
|
|
510
|
+
{
|
|
511
|
+
method: 'PATCH',
|
|
512
|
+
pattern: '/v1/abac_policy_links/:id',
|
|
513
|
+
scope: 'abac:write',
|
|
514
|
+
handler: (c) => {
|
|
515
|
+
const e = c.engine;
|
|
516
|
+
const l = needLink(e, c.params.id);
|
|
517
|
+
const t = templates(e).get(l.template_id);
|
|
518
|
+
if (!t)
|
|
519
|
+
throw new errors_1.EmulatorError('NOT_FOUND', "The link's template is gone.");
|
|
520
|
+
const b = c.body;
|
|
521
|
+
const patch = { updated_at: e.clock.now() };
|
|
522
|
+
if (b.name !== undefined) {
|
|
523
|
+
const name = nameOf(b.name);
|
|
524
|
+
const clash = links(e).find((x) => x.name === name);
|
|
525
|
+
if (clash && clash.id !== l.id)
|
|
526
|
+
throw bad(`An ABAC policy link named "${name}" already exists.`, 'name');
|
|
527
|
+
patch.name = name;
|
|
528
|
+
}
|
|
529
|
+
const pIn = entityRef(b.principal, 'principal');
|
|
530
|
+
const rIn = entityRef(b.resource, 'resource');
|
|
531
|
+
if (pIn !== undefined)
|
|
532
|
+
patch.principal = pIn;
|
|
533
|
+
if (rIn !== undefined)
|
|
534
|
+
patch.resource = rIn;
|
|
535
|
+
instantiate(t.source, pIn === undefined ? (l.principal ?? null) : pIn, rIn === undefined ? (l.resource ?? null) : rIn);
|
|
536
|
+
if (b.enabled !== undefined)
|
|
537
|
+
patch.enabled = Boolean(b.enabled);
|
|
538
|
+
const row = links(e).update(l.id, patch);
|
|
539
|
+
(0, emu4_authz_trust_1.auditAuthz)(e, 'abac_link.updated', 'abac_link', row.id, { name: row.name });
|
|
540
|
+
return { body: linkView(e, row) };
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
method: 'DELETE',
|
|
545
|
+
pattern: '/v1/abac_policy_links/:id',
|
|
546
|
+
scope: 'abac:write',
|
|
547
|
+
handler: (c) => {
|
|
548
|
+
const l = needLink(c.engine, c.params.id);
|
|
549
|
+
links(c.engine).delete(l.id);
|
|
550
|
+
(0, emu4_authz_trust_1.auditAuthz)(c.engine, 'abac_link.deleted', 'abac_link', l.id, { name: l.name });
|
|
551
|
+
return { body: { object: 'abac_policy_link', id: l.id, deleted: true } };
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
];
|
|
555
|
+
}
|
|
556
|
+
// ── access policies: signals, simulation, zone checks ──
|
|
557
|
+
const STAGES = ['sign_in', 'authorize', 'token'];
|
|
558
|
+
const TARGETS = ['user', 'machine', 'agent'];
|
|
559
|
+
const RANK = { mfa: 1, passwordless: 2, phishing_resistant: 3 };
|
|
560
|
+
const PHISHING_RESISTANT = new Set(['passkey', 'webauthn', 'security_key', 'piv', 'certificate', 'x509', 'device_key']);
|
|
561
|
+
const PASSWORDLESS = new Set([...PHISHING_RESISTANT, 'email_link', 'email_code', 'phone_code', 'magic_link', 'otp']);
|
|
562
|
+
const strength = (methods, mfa) => {
|
|
563
|
+
const m = methods.map((x) => x.toLowerCase());
|
|
564
|
+
if (m.some((x) => PHISHING_RESISTANT.has(x)))
|
|
565
|
+
return 'phishing_resistant';
|
|
566
|
+
if (m.length && !m.includes('password') && m.every((x) => PASSWORDLESS.has(x) || x.startsWith('oauth:')) && mfa)
|
|
567
|
+
return 'passwordless';
|
|
568
|
+
return mfa ? 'mfa' : null;
|
|
569
|
+
};
|
|
570
|
+
function simulate(c) {
|
|
571
|
+
const e = c.engine;
|
|
572
|
+
const b = c.body;
|
|
573
|
+
const stage = b.stage === undefined ? 'sign_in' : b.stage;
|
|
574
|
+
if (typeof stage !== 'string' || !STAGES.includes(stage))
|
|
575
|
+
throw bad('stage must be sign_in, authorize or token.', 'stage');
|
|
576
|
+
const strs = (k) => {
|
|
577
|
+
const v = b[k];
|
|
578
|
+
if (v === undefined)
|
|
579
|
+
return undefined;
|
|
580
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== 'string'))
|
|
581
|
+
throw bad(`${k} must be an array of strings.`, k);
|
|
582
|
+
return v;
|
|
583
|
+
};
|
|
584
|
+
const table = e.generic.get('access_policies');
|
|
585
|
+
let rows;
|
|
586
|
+
if (typeof b.policy_id === 'string') {
|
|
587
|
+
const row = table?.get(b.policy_id);
|
|
588
|
+
if (!row)
|
|
589
|
+
throw new errors_1.EmulatorError('NOT_FOUND', 'Unknown access policy.');
|
|
590
|
+
rows = [{ ...row, enabled: true, mode: 'enforce' }];
|
|
591
|
+
}
|
|
592
|
+
else
|
|
593
|
+
rows = (table?.all() ?? []).filter((r) => r.enabled !== false);
|
|
594
|
+
const ip = typeof b.ip === 'string' ? b.ip : undefined;
|
|
595
|
+
const userId = typeof b.user_id === 'string' ? b.user_id : null;
|
|
596
|
+
const sig = (0, access_signals_1.resolveSignals)(e, { userId, ip, explicit: (0, access_signals_1.firstOf)((0, access_signals_1.readSignals)(b, (m, p) => { throw bad(m, p); }), (0, access_signals_1.headerSignals)(c.request.headers)) });
|
|
597
|
+
const user = userId ? e.db.users.get(userId) : undefined;
|
|
598
|
+
const target = TARGETS.includes(b.target_type) ? b.target_type : 'user';
|
|
599
|
+
const ctx = {
|
|
600
|
+
stage,
|
|
601
|
+
applicationId: typeof b.application_id === 'string' ? b.application_id : null,
|
|
602
|
+
userId,
|
|
603
|
+
groupIds: strs('groups') ?? [],
|
|
604
|
+
orgIds: strs('orgs') ?? [],
|
|
605
|
+
roles: strs('roles') ?? [],
|
|
606
|
+
zoneIds: strs('zone_ids') ?? (e.generic.get('network_zones')?.all() ?? []).filter((z) => (0, authz_layers_1.zoneMatches)(e, z, ip)).map((z) => z.id),
|
|
607
|
+
hasIp: !!ip,
|
|
608
|
+
country: sig.country ?? null,
|
|
609
|
+
riskLevel: sig.riskLevel ?? null,
|
|
610
|
+
deviceTrust: sig.deviceTrust ?? null,
|
|
611
|
+
authMethods: sig.authMethods ?? [],
|
|
612
|
+
devicePlatform: sig.devicePlatform ?? null,
|
|
613
|
+
osVersion: sig.osVersion ?? null,
|
|
614
|
+
clientType: sig.clientType ?? null,
|
|
615
|
+
userRiskLevel: sig.userRiskLevel ?? null,
|
|
616
|
+
principalId: typeof b.principal_id === 'string' ? b.principal_id : null,
|
|
617
|
+
userAttributes: b.user_attributes && typeof b.user_attributes === 'object' && !Array.isArray(b.user_attributes) ? b.user_attributes : (user?.public_metadata ?? {}),
|
|
618
|
+
now: new Date(typeof b.now === 'number' ? b.now : e.clock.now()),
|
|
619
|
+
};
|
|
620
|
+
const live = rows.filter((p) => (!p.stages?.length || p.stages.includes(stage)) && (p.targets?.length ? p.targets : ['user']).includes(target)).sort((a, z) => (a.priority ?? 100) - (z.priority ?? 100));
|
|
621
|
+
const enforced = live.filter((p) => p.mode !== 'report_only');
|
|
622
|
+
const report = live.filter((p) => p.mode === 'report_only');
|
|
623
|
+
const dec = (0, authz_layers_1.evalSet)(enforced, ctx);
|
|
624
|
+
const would = report.length ? (0, authz_layers_1.evalSet)(live, ctx) : null;
|
|
625
|
+
// What the matched policies ask of the request.
|
|
626
|
+
const req = { mfa: false, acr: [], policy_acceptance: [], actions: [], auth_strength: null, sign_in_frequency_seconds: null, max_session_age_seconds: null, persistent_session: true, device_bound_session: false };
|
|
627
|
+
const add = (xs, v) => void (v && !xs.includes(v) && xs.push(v));
|
|
628
|
+
for (const p of enforced.filter((x) => dec.matched.includes(x.id))) {
|
|
629
|
+
for (const g of p.grants ?? []) {
|
|
630
|
+
if (g.type === 'require_mfa')
|
|
631
|
+
req.mfa = true;
|
|
632
|
+
else if (g.type === 'require_acr')
|
|
633
|
+
add(req.acr, g.value);
|
|
634
|
+
else if (g.type === 'require_policy_acceptance')
|
|
635
|
+
add(req.policy_acceptance, g.value);
|
|
636
|
+
else if (g.type === 'require_action')
|
|
637
|
+
add(req.actions, g.value);
|
|
638
|
+
else if (g.type === 'require_password_change')
|
|
639
|
+
add(req.actions, 'password_change');
|
|
640
|
+
else if (g.type === 'require_auth_strength')
|
|
641
|
+
req.auth_strength = !req.auth_strength || (RANK[g.value] ?? 0) > (RANK[req.auth_strength] ?? 0) ? g.value : req.auth_strength;
|
|
642
|
+
else if (g.type === 'sign_in_frequency' && typeof g.seconds === 'number')
|
|
643
|
+
req.sign_in_frequency_seconds = Math.min(req.sign_in_frequency_seconds ?? Infinity, g.seconds);
|
|
644
|
+
else if (g.type === 'max_session_age' && typeof g.seconds === 'number')
|
|
645
|
+
req.max_session_age_seconds = Math.min(req.max_session_age_seconds ?? Infinity, g.seconds);
|
|
646
|
+
else if (g.type === 'persistent_session' && g.allow === false)
|
|
647
|
+
req.persistent_session = false;
|
|
648
|
+
else if (g.type === 'require_device_bound_session')
|
|
649
|
+
req.device_bound_session = true;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const stepUp = req.mfa || req.acr.length || req.policy_acceptance.length || req.actions.length || req.auth_strength || req.sign_in_frequency_seconds !== null || req.max_session_age_seconds !== null || req.device_bound_session;
|
|
653
|
+
const decision = dec.blockedBy ? 'block' : stepUp ? 'step_up' : 'allow';
|
|
654
|
+
const mfaVerified = b.mfa_verified === true;
|
|
655
|
+
const acr = strs('acr') ?? [];
|
|
656
|
+
const unmet = [];
|
|
657
|
+
if (req.mfa && !mfaVerified)
|
|
658
|
+
unmet.push('mfa');
|
|
659
|
+
for (const a of req.acr)
|
|
660
|
+
if (!acr.includes(a))
|
|
661
|
+
unmet.push(`acr:${a}`);
|
|
662
|
+
for (const p of req.policy_acceptance)
|
|
663
|
+
if (!(strs('accepted_policies') ?? []).includes(p))
|
|
664
|
+
unmet.push(`policy_acceptance:${p}`);
|
|
665
|
+
for (const a of req.actions)
|
|
666
|
+
if (a === 'password_change' ? !b.password_changed && !(strs('completed_actions') ?? []).includes(a) : !(strs('completed_actions') ?? []).includes(a))
|
|
667
|
+
unmet.push(`action:${a}`);
|
|
668
|
+
if (req.auth_strength) {
|
|
669
|
+
const have = strength(ctx.authMethods, mfaVerified);
|
|
670
|
+
if (!have || RANK[have] < RANK[req.auth_strength])
|
|
671
|
+
unmet.push(`auth_strength:${req.auth_strength}`);
|
|
672
|
+
}
|
|
673
|
+
if (req.sign_in_frequency_seconds !== null && !((typeof b.last_auth_age_seconds === 'number' ? b.last_auth_age_seconds : Infinity) <= req.sign_in_frequency_seconds))
|
|
674
|
+
unmet.push('sign_in_frequency');
|
|
675
|
+
if (req.max_session_age_seconds !== null && !((typeof b.session_age_seconds === 'number' ? b.session_age_seconds : 0) <= req.max_session_age_seconds))
|
|
676
|
+
unmet.push('max_session_age');
|
|
677
|
+
if (req.device_bound_session && b.device_bound_session !== true)
|
|
678
|
+
unmet.push('device_bound_session');
|
|
679
|
+
const wouldReport = would && would.matched.some((id) => report.some((r) => r.id === id)) ? would : null;
|
|
680
|
+
return { object: 'access_policy_simulation', decision, matched: dec.matched, blocked_by: dec.blockedBy ?? null, requirements: req, unmet, report_only: wouldReport ? { decision: wouldReport.decision, matched: wouldReport.matched, blocked_by: wouldReport.blockedBy ?? null } : null };
|
|
681
|
+
}
|
|
682
|
+
const signalWire = (s) => ({ ...(s.country ? { country: s.country } : {}), ...(s.riskLevel ? { risk_level: s.riskLevel } : {}), ...(s.userRiskLevel ? { user_risk_level: s.userRiskLevel } : {}), ...(s.deviceTrust ? { device_trust: s.deviceTrust } : {}), ...(s.authMethods ? { auth_methods: s.authMethods } : {}), ...(s.clientType ? { client_type: s.clientType } : {}), ...(s.devicePlatform ? { device_platform: s.devicePlatform } : {}), ...(s.osVersion ? { os_version: s.osVersion } : {}) });
|
|
683
|
+
const signalDump = (e) => {
|
|
684
|
+
const s = (0, access_signals_1.signalState)(e);
|
|
685
|
+
return { defaults: signalWire(s.defaults), users: Object.fromEntries([...s.users].map(([k, v]) => [k, signalWire(v)])), ips: Object.fromEntries(s.ips) };
|
|
686
|
+
};
|
|
687
|
+
function accessHandlers() {
|
|
688
|
+
return [
|
|
689
|
+
{ method: 'POST', pattern: '/v1/access_policies/simulate', scope: 'access_policies:read', handler: (c) => ({ body: simulate(c) }) },
|
|
690
|
+
{
|
|
691
|
+
method: 'POST',
|
|
692
|
+
pattern: '/v1/network_zones/:id/check',
|
|
693
|
+
scope: 'network_acls:read',
|
|
694
|
+
handler: (c) => {
|
|
695
|
+
const e = c.engine;
|
|
696
|
+
const z = e.generic.get('network_zones')?.get(c.params.id);
|
|
697
|
+
if (!z)
|
|
698
|
+
throw new errors_1.EmulatorError('NOT_FOUND', 'Unknown network zone.');
|
|
699
|
+
const ip = c.body.ip;
|
|
700
|
+
if (typeof ip !== 'string' || !ip.trim())
|
|
701
|
+
throw bad('ip is required.', 'ip');
|
|
702
|
+
const f = (0, access_signals_1.ipFacts)(e, ip.trim());
|
|
703
|
+
// shortcut: the zone's enabled flag is not consulted (the generic zone row defaults it to false).
|
|
704
|
+
return { body: { object: 'network_zone_check', zone_id: z.id, ip: ip.trim(), matches: (0, authz_layers_1.zoneMatches)(e, z, ip.trim()), facts: { country: f.country, asn: f.asn, tor: f.tor, datacenter: f.datacenter } } };
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
// ── emulator control: where an address is, and the risk and device signals ──
|
|
708
|
+
{ method: 'GET', pattern: '/__emulator/access/signals', public: true, handler: (c) => ({ body: signalDump(c.engine) }) },
|
|
709
|
+
{
|
|
710
|
+
// Instance-wide default signals, or one user's with `user_id`. A PUT replaces that set.
|
|
711
|
+
method: 'PUT',
|
|
712
|
+
pattern: '/__emulator/access/signals',
|
|
713
|
+
public: true,
|
|
714
|
+
handler: (c) => {
|
|
715
|
+
const s = (0, access_signals_1.signalState)(c.engine);
|
|
716
|
+
const sig = (0, access_signals_1.readSignals)(c.body, (m, p) => { throw bad(m, p); });
|
|
717
|
+
if (c.body.user_id !== undefined) {
|
|
718
|
+
if (typeof c.body.user_id !== 'string' || !c.body.user_id)
|
|
719
|
+
throw bad('user_id must be a user id.', 'user_id');
|
|
720
|
+
s.users.set(c.body.user_id, sig);
|
|
721
|
+
}
|
|
722
|
+
else
|
|
723
|
+
s.defaults = sig;
|
|
724
|
+
return { body: signalDump(c.engine) };
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
// Facts for addresses: `{ ips: { "203.0.113.5": { country, asn, tor, datacenter } } }` (merged by address).
|
|
729
|
+
method: 'PUT',
|
|
730
|
+
pattern: '/__emulator/access/ip_facts',
|
|
731
|
+
public: true,
|
|
732
|
+
handler: (c) => {
|
|
733
|
+
const s = (0, access_signals_1.signalState)(c.engine);
|
|
734
|
+
const ips = obj(c.body.ips);
|
|
735
|
+
for (const [ip, raw] of Object.entries(ips)) {
|
|
736
|
+
const f = obj(raw);
|
|
737
|
+
if (f.country !== undefined && f.country !== null && !(typeof f.country === 'string' && /^[A-Za-z]{2}$/.test(f.country)))
|
|
738
|
+
throw bad('country is a two-letter code.', `ips.${ip}.country`);
|
|
739
|
+
if (f.asn !== undefined && f.asn !== null && !Number.isInteger(f.asn))
|
|
740
|
+
throw bad('asn is an integer.', `ips.${ip}.asn`);
|
|
741
|
+
s.ips.set(ip, { ...(0, access_signals_1.noFacts)(), country: typeof f.country === 'string' ? f.country.toUpperCase() : null, asn: typeof f.asn === 'number' ? f.asn : null, tor: typeof f.tor === 'boolean' ? f.tor : null, datacenter: typeof f.datacenter === 'boolean' ? f.datacenter : null });
|
|
742
|
+
}
|
|
743
|
+
return { body: signalDump(c.engine) };
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
{ method: 'DELETE', pattern: '/__emulator/access/signals', public: true, handler: (c) => (((0, access_signals_1.signalState)(c.engine).ips.clear(), (0, access_signals_1.signalState)(c.engine).users.clear(), ((0, access_signals_1.signalState)(c.engine).defaults = {})), { body: { ok: true } }) },
|
|
747
|
+
];
|
|
748
|
+
}
|
|
749
|
+
function emu4AuthzHandlers() {
|
|
750
|
+
return [...fgaHandlers(), ...templateHandlers(), ...accessHandlers(), ...(0, emu4_authz_trust_1.emu4TrustAuthzHandlers)()];
|
|
751
|
+
}
|
|
752
|
+
//# sourceMappingURL=emu4-authz.js.map
|