@friggframework/core 2.0.0--canary.680.daae24e.0 → 2.0.0--canary.686.e94de4d.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +0 -11
- package/CLAUDE.md +28 -0
- package/README.md +105 -0
- package/application/commands/integration-commands.js +2 -2
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- package/credential/use-cases/credential-operations-for-user.js +110 -25
- package/credential/use-cases/reauthorize-credential-for-user.js +15 -8
- package/database/config.js +48 -24
- package/database/documentdb-utils.js +6 -1
- package/database/encryption/README.md +0 -2
- package/database/encryption/encryption-config.js +10 -31
- package/database/encryption/encryption-schema-registry.js +99 -47
- package/database/encryption/field-encryption-service.js +5 -69
- package/database/organization-where.js +18 -0
- package/database/prisma.js +4 -2
- package/database/unique-violation.js +25 -0
- package/extensions/bootstrap-runner.js +43 -0
- package/extensions/extension-loader.js +187 -0
- package/extensions/index.js +41 -0
- package/extensions/initialize-app.js +123 -0
- package/extensions/route-mounter.js +103 -0
- package/extensions/schema-composer.js +188 -0
- package/generated/prisma-mongodb/edge.js +27 -4
- package/generated/prisma-mongodb/index-browser.js +24 -1
- package/generated/prisma-mongodb/index.d.ts +3227 -47
- package/generated/prisma-mongodb/index.js +27 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +65 -0
- package/generated/prisma-mongodb/wasm.js +27 -4
- package/generated/prisma-postgresql/edge.js +27 -4
- package/generated/prisma-postgresql/index-browser.js +24 -1
- package/generated/prisma-postgresql/index.d.ts +3519 -67
- package/generated/prisma-postgresql/index.js +27 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +61 -0
- package/generated/prisma-postgresql/wasm.js +27 -4
- package/handlers/app-definition-loader.js +103 -12
- package/handlers/routers/auth.js +40 -12
- package/handlers/routers/health.js +4 -2
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +102 -64
- package/index.js +63 -1
- package/infrastructure/scheduler/index.js +19 -6
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
- package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
- package/integrations/index.js +5 -0
- package/integrations/integration-router.js +115 -73
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +35 -3
- package/integrations/repositories/integration-repository-interface.js +17 -1
- package/integrations/repositories/integration-repository-mongo.js +59 -76
- package/integrations/repositories/integration-repository-postgres.js +62 -76
- package/integrations/schedule-config.js +80 -0
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +55 -20
- package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
- package/integrations/use-cases/get-integrations-for-user.js +80 -40
- package/integrations/use-cases/index.js +7 -1
- package/integrations/use-cases/integration-operations-for-user.js +55 -24
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/integrations/utils/map-integration-dto.js +44 -1
- package/management-api/management-api-config.js +71 -2
- package/management-api/meta-router.js +21 -2
- package/management-api/openapi/components.js +40 -0
- package/management-api/openapi/v2-operations.js +16 -4
- package/management-api/route-registry.js +123 -6
- package/management-api/v2/dependencies.js +40 -15
- package/management-api/v2/dto.js +20 -0
- package/management-api/v2/handlers/authorization.js +1 -0
- package/management-api/v2/handlers/credentials.js +6 -2
- package/management-api/v2/handlers/entities.js +10 -7
- package/management-api/v2/handlers/integrations.js +6 -3
- package/management-api/v2/request.js +32 -1
- package/modules/entity-scope.js +15 -0
- package/modules/index.js +8 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +53 -2
- package/modules/repositories/entity-not-found.js +18 -0
- package/modules/repositories/module-repository-documentdb.js +51 -6
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +67 -74
- package/modules/repositories/module-repository-postgres.js +66 -74
- package/modules/repositories/module-repository.js +2 -1
- package/modules/scope-config.js +192 -0
- package/modules/scope-policy.js +112 -0
- package/modules/scope-resolution.js +288 -0
- package/modules/scope-visibility.js +247 -0
- package/modules/tests/doubles/ownership-matching.js +18 -0
- package/modules/use-cases/authorize-entity-for-user.js +61 -0
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/entity-operations-for-user.js +50 -13
- package/modules/use-cases/entity-types.js +31 -7
- package/modules/use-cases/execute-entity-proxy-request.js +50 -3
- package/modules/use-cases/get-authorization-requirements.js +2 -2
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +34 -7
- package/modules/use-cases/get-entity-options-by-id.js +10 -4
- package/modules/use-cases/get-entity-options-by-type.js +1 -1
- package/modules/use-cases/get-module-instance-from-type.js +1 -1
- package/modules/use-cases/get-module.js +10 -4
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +40 -13
- package/modules/use-cases/process-authorization-callback.js +53 -19
- package/modules/use-cases/refresh-entity-options.js +10 -4
- package/modules/use-cases/test-module-auth.js +10 -4
- package/package.json +6 -5
- package/prisma-mongodb/schema.prisma +65 -0
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
- package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
- package/prisma-postgresql/schema.prisma +61 -0
- package/providers/index.js +23 -0
- package/providers/resolve-provider.js +93 -0
- package/providers/runtime-detection.js +124 -0
- package/queues/index.js +25 -0
- package/queues/providers/index.js +11 -0
- package/queues/providers/netlify-background-provider.js +150 -0
- package/queues/providers/qstash-queue-provider.js +219 -0
- package/queues/providers/sqs-queue-provider.js +70 -0
- package/queues/queue-client-interface.js +55 -0
- package/queues/queue-message-auth.js +193 -0
- package/queues/queue-provider-factory.js +101 -0
- package/queues/queue-provider.js +65 -0
- package/queues/sqs-queue-client.js +81 -0
- package/types/index.d.ts +1 -0
- package/types/modules/index.d.ts +139 -0
- package/types/user/index.d.ts +134 -0
- package/user/adopter-jwt-config.js +177 -0
- package/user/adopter-jwt-errors.js +59 -0
- package/user/adopter-token-verifier.js +122 -0
- package/user/app-user-helpers.js +97 -0
- package/user/create-user-authentication.js +64 -0
- package/user/membership.js +51 -0
- package/user/repositories/organization-membership-record.js +19 -0
- package/user/repositories/organization-membership-repository-documentdb.js +106 -0
- package/user/repositories/organization-membership-repository-factory.js +42 -0
- package/user/repositories/organization-membership-repository-interface.js +57 -0
- package/user/repositories/organization-membership-repository-mongo.js +13 -0
- package/user/repositories/organization-membership-repository-postgres.js +29 -0
- package/user/repositories/organization-membership-repository-prisma.js +81 -0
- package/user/repositories/user-repository-documentdb.js +27 -2
- package/user/repositories/user-repository-interface.js +18 -0
- package/user/repositories/user-repository-mongo.js +24 -0
- package/user/repositories/user-repository-postgres.js +18 -0
- package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/use-cases/ensure-organization-membership.js +92 -0
- package/user/use-cases/find-individual-users-by-organization.js +54 -0
- package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
- package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
- package/user/user.js +64 -1
- package/database/encryption/test-support/fake-ciphertext.js +0 -15
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and validating ownership settings in an app definition (ADR-056
|
|
3
|
+
* §3.1, §3.3). Called once by loadAppDefinition, so a bad setting stops the app
|
|
4
|
+
* at load, not on the first request. No I/O.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
SCOPES,
|
|
9
|
+
INDIVIDUAL_ACROSS_VALUES,
|
|
10
|
+
OWNER_RULES,
|
|
11
|
+
DEFAULT_ORG_ADMIN_ROLES,
|
|
12
|
+
configError,
|
|
13
|
+
isPlainObject,
|
|
14
|
+
parseScopeSetting,
|
|
15
|
+
resolveScope,
|
|
16
|
+
} = require('./scope-resolution');
|
|
17
|
+
const { entityTypeNameOf } = require('./entity-type-registry');
|
|
18
|
+
|
|
19
|
+
/** The binding scope an integration declares for `modules[key]`, if any. */
|
|
20
|
+
function bindingScopeSetting(Definition, key) {
|
|
21
|
+
const binding = Definition?.modules?.[key];
|
|
22
|
+
if (binding?.scope !== undefined) return binding.scope;
|
|
23
|
+
const entityEntry = Definition?.entities?.[key];
|
|
24
|
+
if (entityEntry?.scope !== undefined) return entityEntry.scope;
|
|
25
|
+
// Deprecated alias from the global-entities branch: `global: true`.
|
|
26
|
+
if (entityEntry?.global === true) return SCOPES.GLOBAL;
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readAppOwnership(appDefinition) {
|
|
31
|
+
const raw = appDefinition?.ownership;
|
|
32
|
+
if (raw === undefined || raw === null) {
|
|
33
|
+
return { scope: null, individualAcross: 'organization' };
|
|
34
|
+
}
|
|
35
|
+
if (!isPlainObject(raw)) {
|
|
36
|
+
throw configError('appDefinition.ownership', 'must be an object');
|
|
37
|
+
}
|
|
38
|
+
const unknown = Object.keys(raw).filter(
|
|
39
|
+
(key) => key !== 'scope' && key !== 'individualAcross'
|
|
40
|
+
);
|
|
41
|
+
if (unknown.length) {
|
|
42
|
+
throw configError(
|
|
43
|
+
'appDefinition.ownership',
|
|
44
|
+
`unknown setting(s): ${unknown.join(', ')}`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const individualAcross = raw.individualAcross ?? 'organization';
|
|
48
|
+
if (!INDIVIDUAL_ACROSS_VALUES.includes(individualAcross)) {
|
|
49
|
+
throw configError(
|
|
50
|
+
'appDefinition.ownership.individualAcross',
|
|
51
|
+
`must be one of ${INDIVIDUAL_ACROSS_VALUES.join(', ')}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
scope: parseScopeSetting(raw.scope, 'appDefinition.ownership.scope'),
|
|
56
|
+
individualAcross,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readIntegrationOwnership(integrationClass) {
|
|
61
|
+
const name = integrationClass?.Definition?.name || 'unnamed';
|
|
62
|
+
const raw = integrationClass?.Definition?.ownership;
|
|
63
|
+
if (raw === undefined || raw === null) return { scope: null, owner: 'derive' };
|
|
64
|
+
const where = `integration '${name}' ownership`;
|
|
65
|
+
if (!isPlainObject(raw)) throw configError(where, 'must be an object');
|
|
66
|
+
const unknown = Object.keys(raw).filter(
|
|
67
|
+
(key) => key !== 'scope' && key !== 'owner'
|
|
68
|
+
);
|
|
69
|
+
if (unknown.length) {
|
|
70
|
+
throw configError(where, `unknown setting(s): ${unknown.join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
const owner = raw.owner ?? 'derive';
|
|
73
|
+
if (!OWNER_RULES.includes(owner)) {
|
|
74
|
+
throw configError(where, `owner must be one of ${OWNER_RULES.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
return { scope: parseScopeSetting(raw.scope, `${where}.scope`), owner };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `user.orgAdminRoles`: the token roles that may manage organization-level
|
|
81
|
+
* things. Default `['ADMIN']`. A shared-secret caller needs no role.
|
|
82
|
+
*/
|
|
83
|
+
function readOrgAdminRoles(appDefinition) {
|
|
84
|
+
const raw = appDefinition?.user?.orgAdminRoles;
|
|
85
|
+
if (raw === undefined || raw === null) return [...DEFAULT_ORG_ADMIN_ROLES];
|
|
86
|
+
if (
|
|
87
|
+
!Array.isArray(raw) ||
|
|
88
|
+
raw.length === 0 ||
|
|
89
|
+
raw.some((role) => typeof role !== 'string' || role.trim() === '')
|
|
90
|
+
) {
|
|
91
|
+
throw configError(
|
|
92
|
+
'appDefinition.user.orgAdminRoles',
|
|
93
|
+
'must be a non-empty array of role names'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return [...raw];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `user.orgAdminGateBearerTokens`: judge Frigg bearer-token callers by role
|
|
101
|
+
* like adopter-token callers. Default false: they keep their pre-roles
|
|
102
|
+
* behaviour and may manage the organization they act in.
|
|
103
|
+
*/
|
|
104
|
+
function readGateBearerTokens(appDefinition) {
|
|
105
|
+
const raw = appDefinition?.user?.orgAdminGateBearerTokens;
|
|
106
|
+
if (raw === undefined || raw === null) return false;
|
|
107
|
+
if (typeof raw !== 'boolean') {
|
|
108
|
+
throw configError('appDefinition.user.orgAdminGateBearerTokens', 'must be a boolean');
|
|
109
|
+
}
|
|
110
|
+
return raw;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every binding of every integration with its resolved scope and where it came
|
|
115
|
+
* from, parsing each integration's settings once. Throws OwnershipConfigError
|
|
116
|
+
* for any invalid one. Exported for tooling that wants to show the chain.
|
|
117
|
+
*/
|
|
118
|
+
function listBindingScopes(appDefinition, entityTypeRegistry, primary) {
|
|
119
|
+
const app = readAppOwnership(appDefinition);
|
|
120
|
+
const rows = [];
|
|
121
|
+
for (const integrationClass of appDefinition?.integrations || []) {
|
|
122
|
+
const Definition = integrationClass?.Definition;
|
|
123
|
+
const name = Definition?.name || 'unnamed';
|
|
124
|
+
const ownership = readIntegrationOwnership(integrationClass);
|
|
125
|
+
for (const [key, binding] of Object.entries(Definition?.modules || {})) {
|
|
126
|
+
const moduleName = entityTypeNameOf(binding?.definition);
|
|
127
|
+
const resolved = resolveScope({
|
|
128
|
+
binding: parseScopeSetting(
|
|
129
|
+
bindingScopeSetting(Definition, key),
|
|
130
|
+
`integration '${name}' binding '${key}' scope`
|
|
131
|
+
),
|
|
132
|
+
integration: ownership.scope,
|
|
133
|
+
entityType: moduleName
|
|
134
|
+
? entityTypeRegistry?.get(moduleName)?.settings?.scope
|
|
135
|
+
: undefined,
|
|
136
|
+
app: app.scope,
|
|
137
|
+
primary,
|
|
138
|
+
});
|
|
139
|
+
rows.push({
|
|
140
|
+
integration: name,
|
|
141
|
+
binding: key,
|
|
142
|
+
moduleName: moduleName ?? null,
|
|
143
|
+
scope: resolved.scope,
|
|
144
|
+
source: resolved.source,
|
|
145
|
+
owner: ownership.owner,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return rows;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Validates every ownership setting in one pass and returns the app-level
|
|
154
|
+
* config the runtime needs.
|
|
155
|
+
*
|
|
156
|
+
* @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string, orgAdminRoles: string[], gateBearerTokens: boolean}}
|
|
157
|
+
* @throws {OwnershipConfigError}
|
|
158
|
+
*/
|
|
159
|
+
function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
|
|
160
|
+
const app = readAppOwnership(appDefinition);
|
|
161
|
+
for (const [name, entry] of entityTypeRegistry || []) {
|
|
162
|
+
parseScopeSetting(entry?.settings?.scope, `entity type '${name}' scope`);
|
|
163
|
+
}
|
|
164
|
+
const rows = listBindingScopes(
|
|
165
|
+
appDefinition,
|
|
166
|
+
entityTypeRegistry,
|
|
167
|
+
appDefinition?.user?.primary
|
|
168
|
+
);
|
|
169
|
+
// §3.3: a shared integration must not run on one person's credential.
|
|
170
|
+
for (const row of rows) {
|
|
171
|
+
if (row.owner === 'organization' && row.scope === SCOPES.INDIVIDUAL) {
|
|
172
|
+
throw configError(
|
|
173
|
+
`integration '${row.integration}' binding '${row.binding}'`,
|
|
174
|
+
"an organization-owned integration cannot bind an 'individual' entity"
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
scope: app.scope,
|
|
180
|
+
individualAcross: app.individualAcross,
|
|
181
|
+
orgAdminRoles: Object.freeze(readOrgAdminRoles(appDefinition)),
|
|
182
|
+
gateBearerTokens: readGateBearerTokens(appDefinition),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
bindingScopeSetting,
|
|
188
|
+
readAppOwnership,
|
|
189
|
+
readIntegrationOwnership,
|
|
190
|
+
listBindingScopes,
|
|
191
|
+
resolveOwnershipConfig,
|
|
192
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The object a use case asks about scopes (ADR-056 §3): the app's ownership
|
|
3
|
+
* config and entity type registry bound once, as closures. With no arguments it
|
|
4
|
+
* is the behaviour of an app that configures nothing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
INDIVIDUAL_ACROSS_VALUES,
|
|
9
|
+
DEFAULT_ORG_ADMIN_ROLES,
|
|
10
|
+
configError,
|
|
11
|
+
parseScopeSetting,
|
|
12
|
+
resolveScope,
|
|
13
|
+
resolveOwnerForScope,
|
|
14
|
+
} = require('./scope-resolution');
|
|
15
|
+
const {
|
|
16
|
+
accessContext,
|
|
17
|
+
accessToOwner,
|
|
18
|
+
describeRecord,
|
|
19
|
+
} = require('./scope-visibility');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {Object} [params]
|
|
23
|
+
* @param {{scope: *, individualAcross: string, orgAdminRoles?: string[], gateBearerTokens?: boolean}} [params.ownership] - the raw
|
|
24
|
+
* `appDefinition.ownership` or what resolveOwnershipConfig returned (a parsed
|
|
25
|
+
* `{ default, allow }` parses to itself)
|
|
26
|
+
* @param {Map} [params.entityTypeRegistry]
|
|
27
|
+
*/
|
|
28
|
+
function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
|
|
29
|
+
const appScope = parseScopeSetting(
|
|
30
|
+
ownership?.scope,
|
|
31
|
+
'appDefinition.ownership.scope'
|
|
32
|
+
);
|
|
33
|
+
const individualAcross = ownership?.individualAcross ?? 'organization';
|
|
34
|
+
if (!INDIVIDUAL_ACROSS_VALUES.includes(individualAcross)) {
|
|
35
|
+
throw configError(
|
|
36
|
+
'appDefinition.ownership.individualAcross',
|
|
37
|
+
`must be one of ${INDIVIDUAL_ACROSS_VALUES.join(', ')}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const orgAdminRoles = ownership?.orgAdminRoles ?? DEFAULT_ORG_ADMIN_ROLES;
|
|
42
|
+
const gateBearerTokens = ownership?.gateBearerTokens === true;
|
|
43
|
+
|
|
44
|
+
const contextFor = (user) =>
|
|
45
|
+
accessContext(user, { individualAcross, orgAdminRoles, gateBearerTokens });
|
|
46
|
+
|
|
47
|
+
/** The scope a new connection of `entityType` gets (levels 1, 4, 5, 6). */
|
|
48
|
+
const resolveEntityScope = ({ entityType, requestScope, binding, integration, user }) =>
|
|
49
|
+
resolveScope({
|
|
50
|
+
request: requestScope,
|
|
51
|
+
binding,
|
|
52
|
+
integration,
|
|
53
|
+
entityType: entityTypeRegistry?.get?.(entityType)?.settings?.scope,
|
|
54
|
+
app: appScope,
|
|
55
|
+
primary: user?.config?.primary,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Scope, then owner and organization, for a new entity. `legacyOrgId` (the
|
|
60
|
+
* person's legacy organization pointer) rides along so a later lookup can
|
|
61
|
+
* reuse an entity written before scoping.
|
|
62
|
+
*/
|
|
63
|
+
const resolveOwnership = ({ entityType, requestScope, user }) => {
|
|
64
|
+
const ctx = contextFor(user);
|
|
65
|
+
const resolved = resolveEntityScope({ entityType, requestScope, user });
|
|
66
|
+
return {
|
|
67
|
+
...resolveOwnerForScope(resolved.scope, ctx),
|
|
68
|
+
legacyOrgId: ctx.legacyOrgId,
|
|
69
|
+
source: resolved.source,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The owner and organization for a scope that is already decided: a
|
|
75
|
+
* re-authorization keeps the scope its connection has, whatever the config
|
|
76
|
+
* says now. The admin gate applies as it does on create.
|
|
77
|
+
*/
|
|
78
|
+
const ownershipForScope = ({ scope, user }) => {
|
|
79
|
+
const ctx = contextFor(user);
|
|
80
|
+
return {
|
|
81
|
+
...resolveOwnerForScope(scope, ctx),
|
|
82
|
+
legacyOrgId: ctx.legacyOrgId,
|
|
83
|
+
source: 'fixed',
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Whether `user` may load this stored entity for use (same clauses as the list). */
|
|
88
|
+
const canUseEntity = (user, entity) =>
|
|
89
|
+
describeRecord(entity, contextFor(user), 'entity') !== null;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What `user` may do with a record owned by `ownerId` that has no
|
|
93
|
+
* organization of its own (a credential): manage, or null when not theirs.
|
|
94
|
+
*/
|
|
95
|
+
const accessToOwnedBy = (user, ownerId) => accessToOwner(ownerId, contextFor(user));
|
|
96
|
+
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
individualAcross,
|
|
99
|
+
orgAdminRoles,
|
|
100
|
+
accessToOwnedBy,
|
|
101
|
+
accessContext: contextFor,
|
|
102
|
+
resolveScope: resolveEntityScope,
|
|
103
|
+
resolveOwnership,
|
|
104
|
+
ownershipForScope,
|
|
105
|
+
canUseEntity,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The policy of an app that configures nothing; what a use case gets by default. */
|
|
110
|
+
const DEFAULT_SCOPE_POLICY = createScopePolicy();
|
|
111
|
+
|
|
112
|
+
module.exports = { createScopePolicy, DEFAULT_SCOPE_POLICY };
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope resolution and ownership on create (ADR-056 §3.1, §3.2, §3.6).
|
|
3
|
+
*
|
|
4
|
+
* No I/O: everything here is a function of what it is handed (settings, a
|
|
5
|
+
* request's identity context, stored rows). This file also holds the shared
|
|
6
|
+
* constants and `OwnershipConfigError`, so `scope-config.js` (which validates
|
|
7
|
+
* app definitions) and `scope-visibility.js` can import them without a cycle.
|
|
8
|
+
*
|
|
9
|
+
* There is no `scope` column. The owner's user type already says it: no owner
|
|
10
|
+
* is `global`, the active organization's user is `organization`, the caller's
|
|
11
|
+
* individual user is `individual` (docs/guides/entity-scopes.md).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const Boom = require('@hapi/boom');
|
|
15
|
+
const { BaseError } = require('../errors/base-error');
|
|
16
|
+
const { isPresent, sameId } = require('../user/membership');
|
|
17
|
+
|
|
18
|
+
const SCOPES = Object.freeze({
|
|
19
|
+
INDIVIDUAL: 'individual',
|
|
20
|
+
ORGANIZATION: 'organization',
|
|
21
|
+
GLOBAL: 'global',
|
|
22
|
+
});
|
|
23
|
+
const SCOPE_VALUES = Object.freeze(Object.values(SCOPES));
|
|
24
|
+
const INDIVIDUAL_ACROSS_VALUES = Object.freeze(['organization', 'all']);
|
|
25
|
+
const OWNER_RULES = Object.freeze(['derive', 'individual', 'organization']);
|
|
26
|
+
const ACCESS = Object.freeze({ MANAGE: 'manage', USE: 'use', VIEW: 'view' });
|
|
27
|
+
|
|
28
|
+
/** The organization roles that may manage organization-level things unless `user.orgAdminRoles` says. */
|
|
29
|
+
const DEFAULT_ORG_ADMIN_ROLES = Object.freeze(['ADMIN']);
|
|
30
|
+
|
|
31
|
+
/** Raised at app-definition load for an invalid ownership setting. */
|
|
32
|
+
class OwnershipConfigError extends BaseError {
|
|
33
|
+
constructor(message, options) {
|
|
34
|
+
super(message, options);
|
|
35
|
+
this.code = 'OWNERSHIP_CONFIG_INVALID';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isPlainObject = (value) =>
|
|
40
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
41
|
+
|
|
42
|
+
const configError = (where, message) =>
|
|
43
|
+
new OwnershipConfigError(`${where}: ${message}`);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One level's setting: a scope (`'organization'`) or a default plus what a
|
|
47
|
+
* request may choose (`{ default, allow }`). Returns `{ default, allow }`, or
|
|
48
|
+
* null when the level sets nothing. Idempotent on its own output.
|
|
49
|
+
*/
|
|
50
|
+
function parseScopeSetting(setting, where) {
|
|
51
|
+
if (setting === undefined || setting === null) return null;
|
|
52
|
+
if (typeof setting === 'string') {
|
|
53
|
+
if (!SCOPE_VALUES.includes(setting)) {
|
|
54
|
+
throw configError(
|
|
55
|
+
where,
|
|
56
|
+
`scope '${setting}' is not one of ${SCOPE_VALUES.join(', ')}`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return { default: setting, allow: [setting] };
|
|
60
|
+
}
|
|
61
|
+
if (!isPlainObject(setting)) {
|
|
62
|
+
throw configError(where, 'scope must be a scope name or { default, allow }');
|
|
63
|
+
}
|
|
64
|
+
const unknown = Object.keys(setting).filter(
|
|
65
|
+
(key) => key !== 'default' && key !== 'allow'
|
|
66
|
+
);
|
|
67
|
+
if (unknown.length) {
|
|
68
|
+
throw configError(where, `unknown scope setting(s): ${unknown.join(', ')}`);
|
|
69
|
+
}
|
|
70
|
+
const { allow, default: defaultScope } = setting;
|
|
71
|
+
if (allow !== undefined) {
|
|
72
|
+
if (
|
|
73
|
+
!Array.isArray(allow) ||
|
|
74
|
+
allow.length === 0 ||
|
|
75
|
+
allow.some((value) => !SCOPE_VALUES.includes(value)) ||
|
|
76
|
+
new Set(allow).size !== allow.length
|
|
77
|
+
) {
|
|
78
|
+
throw configError(
|
|
79
|
+
where,
|
|
80
|
+
`scope.allow must list one or more of ${SCOPE_VALUES.join(', ')} once each`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (defaultScope !== undefined && !SCOPE_VALUES.includes(defaultScope)) {
|
|
85
|
+
throw configError(
|
|
86
|
+
where,
|
|
87
|
+
`scope.default '${defaultScope}' is not one of ${SCOPE_VALUES.join(', ')}`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (defaultScope === undefined && (allow === undefined || allow.length !== 1)) {
|
|
91
|
+
throw configError(where, 'scope needs a default (or an allow list of exactly one)');
|
|
92
|
+
}
|
|
93
|
+
const resolvedDefault = defaultScope ?? allow[0];
|
|
94
|
+
const resolvedAllow = allow ?? [resolvedDefault];
|
|
95
|
+
if (!resolvedAllow.includes(resolvedDefault)) {
|
|
96
|
+
throw configError(
|
|
97
|
+
where,
|
|
98
|
+
`scope.default '${resolvedDefault}' must be in scope.allow`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return { default: resolvedDefault, allow: [...resolvedAllow] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The level-6 default: `organization` for an organization-primary app. */
|
|
105
|
+
const defaultScopeFor = (primary) =>
|
|
106
|
+
primary === 'organization' ? SCOPES.ORGANIZATION : SCOPES.INDIVIDUAL;
|
|
107
|
+
|
|
108
|
+
const LEVELS = ['binding', 'integration', 'entityType', 'app'];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The first level that sets a scope (ADR-056 §3.1), each overriding those
|
|
112
|
+
* below it; nothing intersects. Returns `{ default, allow, source }`.
|
|
113
|
+
*/
|
|
114
|
+
function decideLevel({ binding, integration, entityType, app, primary } = {}) {
|
|
115
|
+
const settings = { binding, integration, entityType, app };
|
|
116
|
+
for (const source of LEVELS) {
|
|
117
|
+
const parsed = parseScopeSetting(settings[source], `${source} scope`);
|
|
118
|
+
if (parsed) return { ...parsed, source };
|
|
119
|
+
}
|
|
120
|
+
const fallback = defaultScopeFor(primary);
|
|
121
|
+
return { default: fallback, allow: [fallback], source: 'default' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What a request may choose, from the deciding level's `allow` list only. A
|
|
126
|
+
* tenant request is never given `global`; a scope outside the list is refused.
|
|
127
|
+
* Both are the same refusal (403 SCOPE_NOT_ALLOWED): the caller asked for
|
|
128
|
+
* something this app does not offer.
|
|
129
|
+
*/
|
|
130
|
+
function applyRequest(decided, request) {
|
|
131
|
+
const result = {
|
|
132
|
+
scope: decided.default,
|
|
133
|
+
source: decided.source,
|
|
134
|
+
decidedBy: decided.source,
|
|
135
|
+
allow: decided.allow,
|
|
136
|
+
};
|
|
137
|
+
if (request === undefined || request === null) return result;
|
|
138
|
+
if (!SCOPE_VALUES.includes(request)) {
|
|
139
|
+
throw Boom.badRequest(
|
|
140
|
+
`scope must be one of ${SCOPE_VALUES.filter((s) => s !== SCOPES.GLOBAL).join(', ')}`,
|
|
141
|
+
{ code: 'SCOPE_INVALID', details: { field: 'scope' } }
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (request === SCOPES.GLOBAL) {
|
|
145
|
+
throw Boom.forbidden(
|
|
146
|
+
'global entities are created by the app owner through admin routes, not by a tenant request',
|
|
147
|
+
{ code: 'SCOPE_NOT_ALLOWED' }
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (!decided.allow.includes(request)) {
|
|
151
|
+
throw Boom.forbidden(
|
|
152
|
+
`scope '${request}' is not allowed here; allowed: ${decided.allow.join(', ')}`,
|
|
153
|
+
{
|
|
154
|
+
code: 'SCOPE_NOT_ALLOWED',
|
|
155
|
+
details: { field: 'scope', allowed: decided.allow },
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return { ...result, scope: request, source: 'request' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The scope a new connection gets: decideLevel then applyRequest.
|
|
164
|
+
*
|
|
165
|
+
* @param {Object} levels
|
|
166
|
+
* @param {string} [levels.request] - `scope` on the request
|
|
167
|
+
* @param {*} [levels.binding] - `use(module, { scope })` / `Definition.entities[key].scope`
|
|
168
|
+
* @param {*} [levels.integration] - `Definition.ownership.scope`
|
|
169
|
+
* @param {*} [levels.entityType] - registry `settings.scope` (ADR-059)
|
|
170
|
+
* @param {*} [levels.app] - `appDefinition.ownership.scope`
|
|
171
|
+
* @param {string} [levels.primary] - `user.primary`
|
|
172
|
+
* @returns {{scope: string, source: string, decidedBy: string, allow: string[]}}
|
|
173
|
+
*/
|
|
174
|
+
function resolveScope({ request, ...levels } = {}) {
|
|
175
|
+
return applyRequest(decideLevel(levels), request);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const MISSING_INDIVIDUAL =
|
|
179
|
+
'this request has no individual user; send x-frigg-appuserid (or sign in with a token that names the person)';
|
|
180
|
+
const MISSING_ORGANIZATION =
|
|
181
|
+
'this request has no active organization; send x-frigg-apporgid (or sign in with a token that names the organization)';
|
|
182
|
+
|
|
183
|
+
const missing = (message, field) =>
|
|
184
|
+
Boom.badRequest(message, { code: 'IDENTITY_REQUIRED', details: { field } });
|
|
185
|
+
|
|
186
|
+
const adminRequired = (what) =>
|
|
187
|
+
Boom.forbidden(`only an organization admin can ${what}`, {
|
|
188
|
+
code: 'ORG_ADMIN_REQUIRED',
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The owner and organization for a new entity of `scope` (§3.2):
|
|
193
|
+
*
|
|
194
|
+
* individual -> the person; the active organization (null under 'all')
|
|
195
|
+
* organization -> the active organization's user, in that organization
|
|
196
|
+
* global -> refused here: the tenant API never creates global entities
|
|
197
|
+
*
|
|
198
|
+
* `ctx` is an access context (scope-visibility.accessContext).
|
|
199
|
+
*/
|
|
200
|
+
function resolveOwnerForScope(scope, ctx) {
|
|
201
|
+
if (scope === SCOPES.INDIVIDUAL) {
|
|
202
|
+
if (!ctx.callerId) throw missing(MISSING_INDIVIDUAL, 'x-frigg-appuserid');
|
|
203
|
+
return {
|
|
204
|
+
scope,
|
|
205
|
+
ownerId: ctx.callerId,
|
|
206
|
+
organizationId: ctx.individualAcross === 'all' ? null : ctx.activeOrgId,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (scope === SCOPES.ORGANIZATION) {
|
|
210
|
+
if (!ctx.activeOrgId) throw missing(MISSING_ORGANIZATION, 'x-frigg-apporgid');
|
|
211
|
+
if (!ctx.canManageOrg) throw adminRequired('connect an organization-level account');
|
|
212
|
+
return { scope, ownerId: ctx.activeOrgId, organizationId: ctx.activeOrgId };
|
|
213
|
+
}
|
|
214
|
+
throw Boom.forbidden(
|
|
215
|
+
'global entities are created by the app owner through admin routes, not through the tenant API',
|
|
216
|
+
{ code: 'SCOPE_NOT_ALLOWED' }
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The owner kind of an integration (§3 "Integration ownership"): the
|
|
222
|
+
* organization when every entity it uses is organization-scoped, otherwise the
|
|
223
|
+
* person, unless `Definition.ownership.owner` says. ResolveIntegrationOwnership
|
|
224
|
+
* then refuses an integration whose entities are not all of that one scope.
|
|
225
|
+
*/
|
|
226
|
+
function integrationOwnerKind(entityScopes, rule = 'derive') {
|
|
227
|
+
if (rule === 'individual' || rule === 'organization') return rule;
|
|
228
|
+
return entityScopes.length > 0 &&
|
|
229
|
+
entityScopes.every((scope) => scope === SCOPES.ORGANIZATION)
|
|
230
|
+
? SCOPES.ORGANIZATION
|
|
231
|
+
: SCOPES.INDIVIDUAL;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The owner and organization of a new integration of owner kind `kind`
|
|
236
|
+
* ('individual' | 'organization').
|
|
237
|
+
*/
|
|
238
|
+
function resolveIntegrationOwner(kind, ctx) {
|
|
239
|
+
if (kind === SCOPES.ORGANIZATION) {
|
|
240
|
+
if (!ctx.activeOrgId) throw missing(MISSING_ORGANIZATION, 'x-frigg-apporgid');
|
|
241
|
+
if (!ctx.canManageOrg) throw adminRequired('create an organization-level integration');
|
|
242
|
+
return { ownerId: ctx.activeOrgId, organizationId: ctx.activeOrgId };
|
|
243
|
+
}
|
|
244
|
+
if (!ctx.callerId) throw missing(MISSING_INDIVIDUAL, 'x-frigg-appuserid');
|
|
245
|
+
return { ownerId: ctx.callerId, organizationId: ctx.activeOrgId };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The entity an authorization should reuse. The same provider account in two
|
|
250
|
+
* organizations is two entities (§3.6), so an exact organization match wins.
|
|
251
|
+
* A legacy row (organizationId null, written before scoping) is reused when
|
|
252
|
+
* the visibility rule would show it here: an organization-owned one always
|
|
253
|
+
* (the owner is unambiguous), an individual one only in the person's legacy
|
|
254
|
+
* organization.
|
|
255
|
+
*/
|
|
256
|
+
function pickExistingEntity(candidates, { scope, organizationId, legacyOrgId }) {
|
|
257
|
+
const rows = candidates || [];
|
|
258
|
+
const unscoped = (row) => !isPresent(row.organizationId);
|
|
259
|
+
const exact = rows.find((row) =>
|
|
260
|
+
isPresent(organizationId)
|
|
261
|
+
? sameId(row.organizationId, organizationId)
|
|
262
|
+
: unscoped(row)
|
|
263
|
+
);
|
|
264
|
+
if (exact) return exact;
|
|
265
|
+
const legacyApplies =
|
|
266
|
+
scope === SCOPES.ORGANIZATION || sameId(legacyOrgId, organizationId);
|
|
267
|
+
return legacyApplies ? rows.find(unscoped) ?? null : null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
SCOPES,
|
|
272
|
+
SCOPE_VALUES,
|
|
273
|
+
INDIVIDUAL_ACROSS_VALUES,
|
|
274
|
+
OWNER_RULES,
|
|
275
|
+
ACCESS,
|
|
276
|
+
DEFAULT_ORG_ADMIN_ROLES,
|
|
277
|
+
OwnershipConfigError,
|
|
278
|
+
configError,
|
|
279
|
+
isPlainObject,
|
|
280
|
+
parseScopeSetting,
|
|
281
|
+
decideLevel,
|
|
282
|
+
applyRequest,
|
|
283
|
+
resolveScope,
|
|
284
|
+
resolveOwnerForScope,
|
|
285
|
+
integrationOwnerKind,
|
|
286
|
+
resolveIntegrationOwner,
|
|
287
|
+
pickExistingEntity,
|
|
288
|
+
};
|