@friggframework/core 2.0.0--canary.681.2df9ef0.0 → 2.0.0--canary.682.cbca9e2.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.
Files changed (77) hide show
  1. package/README.md +36 -0
  2. package/application/commands/integration-commands.js +20 -10
  3. package/application/index.js +6 -2
  4. package/database/encryption/encryption-schema-registry.js +7 -0
  5. package/database/organization-where.js +18 -0
  6. package/generated/prisma-mongodb/edge.js +5 -3
  7. package/generated/prisma-mongodb/index-browser.js +2 -0
  8. package/generated/prisma-mongodb/index.d.ts +115 -2
  9. package/generated/prisma-mongodb/index.js +5 -3
  10. package/generated/prisma-mongodb/package.json +1 -1
  11. package/generated/prisma-mongodb/schema.prisma +15 -0
  12. package/generated/prisma-mongodb/wasm.js +5 -3
  13. package/generated/prisma-postgresql/edge.js +5 -3
  14. package/generated/prisma-postgresql/index-browser.js +2 -0
  15. package/generated/prisma-postgresql/index.d.ts +131 -2
  16. package/generated/prisma-postgresql/index.js +5 -3
  17. package/generated/prisma-postgresql/package.json +1 -1
  18. package/generated/prisma-postgresql/schema.prisma +15 -0
  19. package/generated/prisma-postgresql/wasm.js +5 -3
  20. package/handlers/app-definition-loader.js +25 -8
  21. package/handlers/backend-utils.js +31 -25
  22. package/handlers/routers/health.js +1 -4
  23. package/handlers/workers/integration-defined-workers.js +5 -2
  24. package/index.js +10 -2
  25. package/integrations/index.js +0 -4
  26. package/integrations/integration-router.js +99 -52
  27. package/integrations/repositories/integration-not-found.js +14 -0
  28. package/integrations/repositories/integration-repository-documentdb.js +34 -3
  29. package/integrations/repositories/integration-repository-interface.js +17 -1
  30. package/integrations/repositories/integration-repository-mongo.js +59 -76
  31. package/integrations/repositories/integration-repository-postgres.js +62 -76
  32. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  33. package/integrations/use-cases/create-integration.js +55 -20
  34. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  35. package/integrations/use-cases/index.js +5 -1
  36. package/integrations/use-cases/integration-operations-for-user.js +40 -19
  37. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  38. package/integrations/utils/map-integration-dto.js +1 -15
  39. package/management-api/management-api-config.js +3 -0
  40. package/management-api/openapi/components.js +46 -1
  41. package/management-api/openapi/v2-operations.js +14 -1
  42. package/management-api/v2/dependencies.js +56 -14
  43. package/management-api/v2/dto.js +17 -0
  44. package/management-api/v2/handlers/authorization.js +1 -0
  45. package/management-api/v2/handlers/entities.js +10 -7
  46. package/management-api/v2/handlers/integrations.js +6 -3
  47. package/management-api/v2/request.js +32 -1
  48. package/modules/entity-scope.js +15 -0
  49. package/modules/entity-type-registry.js +311 -0
  50. package/modules/index.js +14 -0
  51. package/modules/repositories/module-repository-documentdb.js +41 -1
  52. package/modules/repositories/module-repository-interface.js +19 -2
  53. package/modules/repositories/module-repository-mongo.js +64 -73
  54. package/modules/repositories/module-repository-postgres.js +63 -73
  55. package/modules/scope-config.js +155 -0
  56. package/modules/scope-policy.js +80 -0
  57. package/modules/scope-resolution.js +277 -0
  58. package/modules/scope-visibility.js +165 -0
  59. package/modules/tests/doubles/ownership-matching.js +18 -0
  60. package/modules/use-cases/authorize-entity-for-user.js +59 -0
  61. package/modules/use-cases/entity-operations-for-user.js +42 -10
  62. package/modules/use-cases/entity-types.js +62 -14
  63. package/modules/use-cases/execute-entity-proxy-request.js +15 -0
  64. package/modules/use-cases/get-entities-for-user.js +16 -5
  65. package/modules/use-cases/get-entity-module-for-user.js +23 -3
  66. package/modules/use-cases/get-entity-options-by-id.js +5 -2
  67. package/modules/use-cases/get-module.js +5 -2
  68. package/modules/use-cases/multi-step-authorization.js +36 -12
  69. package/modules/use-cases/process-authorization-callback.js +52 -18
  70. package/modules/use-cases/refresh-entity-options.js +5 -2
  71. package/modules/use-cases/test-module-auth.js +5 -2
  72. package/package.json +5 -5
  73. package/prisma-mongodb/schema.prisma +15 -0
  74. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  75. package/prisma-postgresql/schema.prisma +15 -0
  76. package/types/index.d.ts +1 -0
  77. package/types/modules/index.d.ts +145 -0
@@ -0,0 +1,155 @@
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
+ configError,
12
+ isPlainObject,
13
+ parseScopeSetting,
14
+ resolveScope,
15
+ } = require('./scope-resolution');
16
+ const { entityTypeNameOf } = require('./entity-type-registry');
17
+
18
+ /** The binding scope an integration declares for `modules[key]`, if any. */
19
+ function bindingScopeSetting(Definition, key) {
20
+ const binding = Definition?.modules?.[key];
21
+ if (binding?.scope !== undefined) return binding.scope;
22
+ const entityEntry = Definition?.entities?.[key];
23
+ if (entityEntry?.scope !== undefined) return entityEntry.scope;
24
+ // Deprecated alias from the global-entities branch: `global: true`.
25
+ if (entityEntry?.global === true) return SCOPES.GLOBAL;
26
+ return undefined;
27
+ }
28
+
29
+ function readAppOwnership(appDefinition) {
30
+ const raw = appDefinition?.ownership;
31
+ if (raw === undefined || raw === null) {
32
+ return { scope: null, individualAcross: 'organization' };
33
+ }
34
+ if (!isPlainObject(raw)) {
35
+ throw configError('appDefinition.ownership', 'must be an object');
36
+ }
37
+ const unknown = Object.keys(raw).filter(
38
+ (key) => key !== 'scope' && key !== 'individualAcross'
39
+ );
40
+ if (unknown.length) {
41
+ throw configError(
42
+ 'appDefinition.ownership',
43
+ `unknown setting(s): ${unknown.join(', ')}`
44
+ );
45
+ }
46
+ const individualAcross = raw.individualAcross ?? 'organization';
47
+ if (!INDIVIDUAL_ACROSS_VALUES.includes(individualAcross)) {
48
+ throw configError(
49
+ 'appDefinition.ownership.individualAcross',
50
+ `must be one of ${INDIVIDUAL_ACROSS_VALUES.join(', ')}`
51
+ );
52
+ }
53
+ return {
54
+ scope: parseScopeSetting(raw.scope, 'appDefinition.ownership.scope'),
55
+ individualAcross,
56
+ };
57
+ }
58
+
59
+ function readIntegrationOwnership(integrationClass) {
60
+ const name = integrationClass?.Definition?.name || 'unnamed';
61
+ const raw = integrationClass?.Definition?.ownership;
62
+ if (raw === undefined || raw === null) return { scope: null, owner: 'derive' };
63
+ const where = `integration '${name}' ownership`;
64
+ if (!isPlainObject(raw)) throw configError(where, 'must be an object');
65
+ const unknown = Object.keys(raw).filter(
66
+ (key) => key !== 'scope' && key !== 'owner'
67
+ );
68
+ if (unknown.length) {
69
+ throw configError(where, `unknown setting(s): ${unknown.join(', ')}`);
70
+ }
71
+ const owner = raw.owner ?? 'derive';
72
+ if (!OWNER_RULES.includes(owner)) {
73
+ throw configError(where, `owner must be one of ${OWNER_RULES.join(', ')}`);
74
+ }
75
+ return { scope: parseScopeSetting(raw.scope, `${where}.scope`), owner };
76
+ }
77
+
78
+ /**
79
+ * Every binding of every integration with its resolved scope and where it came
80
+ * from, parsing each integration's settings once. Throws OwnershipConfigError
81
+ * for any invalid one. Exported for tooling that wants to show the chain.
82
+ */
83
+ function listBindingScopes(appDefinition, entityTypeRegistry, primary) {
84
+ const app = readAppOwnership(appDefinition);
85
+ const rows = [];
86
+ for (const integrationClass of appDefinition?.integrations || []) {
87
+ const Definition = integrationClass?.Definition;
88
+ const name = Definition?.name || 'unnamed';
89
+ const ownership = readIntegrationOwnership(integrationClass);
90
+ for (const [key, binding] of Object.entries(Definition?.modules || {})) {
91
+ const moduleName = entityTypeNameOf(binding?.definition);
92
+ const resolved = resolveScope({
93
+ binding: parseScopeSetting(
94
+ bindingScopeSetting(Definition, key),
95
+ `integration '${name}' binding '${key}' scope`
96
+ ),
97
+ integration: ownership.scope,
98
+ entityType: moduleName
99
+ ? entityTypeRegistry?.get(moduleName)?.settings?.scope
100
+ : undefined,
101
+ app: app.scope,
102
+ primary,
103
+ });
104
+ rows.push({
105
+ integration: name,
106
+ binding: key,
107
+ moduleName: moduleName ?? null,
108
+ scope: resolved.scope,
109
+ source: resolved.source,
110
+ owner: ownership.owner,
111
+ });
112
+ }
113
+ }
114
+ return rows;
115
+ }
116
+
117
+ /**
118
+ * Validates every ownership setting in one pass and returns the app-level
119
+ * config the runtime needs.
120
+ *
121
+ * @returns {{scope: ({default: string, allow: string[]}|null), individualAcross: string}}
122
+ * @throws {OwnershipConfigError}
123
+ */
124
+ function resolveOwnershipConfig(appDefinition, entityTypeRegistry) {
125
+ const app = readAppOwnership(appDefinition);
126
+ for (const [name, entry] of entityTypeRegistry || []) {
127
+ parseScopeSetting(entry?.settings?.scope, `entity type '${name}' scope`);
128
+ }
129
+ const rows = listBindingScopes(
130
+ appDefinition,
131
+ entityTypeRegistry,
132
+ appDefinition?.user?.primary
133
+ );
134
+ // §3.3: a shared integration must not run on one person's credential.
135
+ for (const row of rows) {
136
+ if (row.owner === 'organization' && row.scope === SCOPES.INDIVIDUAL) {
137
+ throw configError(
138
+ `integration '${row.integration}' binding '${row.binding}'`,
139
+ "an organization-owned integration cannot bind an 'individual' entity"
140
+ );
141
+ }
142
+ }
143
+ return Object.freeze({
144
+ scope: app.scope,
145
+ individualAcross: app.individualAcross,
146
+ });
147
+ }
148
+
149
+ module.exports = {
150
+ bindingScopeSetting,
151
+ readAppOwnership,
152
+ readIntegrationOwnership,
153
+ listBindingScopes,
154
+ resolveOwnershipConfig,
155
+ };
@@ -0,0 +1,80 @@
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
+ configError,
10
+ parseScopeSetting,
11
+ resolveScope,
12
+ resolveOwnerForScope,
13
+ } = require('./scope-resolution');
14
+ const { accessContext, describeRecord } = require('./scope-visibility');
15
+
16
+ /**
17
+ * @param {Object} [params]
18
+ * @param {{scope: *, individualAcross: string}} [params.ownership] - the raw
19
+ * `appDefinition.ownership` or what resolveOwnershipConfig returned (a parsed
20
+ * `{ default, allow }` parses to itself)
21
+ * @param {Map} [params.entityTypeRegistry]
22
+ */
23
+ function createScopePolicy({ ownership, entityTypeRegistry } = {}) {
24
+ const appScope = parseScopeSetting(
25
+ ownership?.scope,
26
+ 'appDefinition.ownership.scope'
27
+ );
28
+ const individualAcross = ownership?.individualAcross ?? 'organization';
29
+ if (!INDIVIDUAL_ACROSS_VALUES.includes(individualAcross)) {
30
+ throw configError(
31
+ 'appDefinition.ownership.individualAcross',
32
+ `must be one of ${INDIVIDUAL_ACROSS_VALUES.join(', ')}`
33
+ );
34
+ }
35
+
36
+ const contextFor = (user) => accessContext(user, { individualAcross });
37
+
38
+ /** The scope a new connection of `entityType` gets (levels 1, 4, 5, 6). */
39
+ const resolveEntityScope = ({ entityType, requestScope, binding, integration, user }) =>
40
+ resolveScope({
41
+ request: requestScope,
42
+ binding,
43
+ integration,
44
+ entityType: entityTypeRegistry?.get?.(entityType)?.settings?.scope,
45
+ app: appScope,
46
+ primary: user?.config?.primary,
47
+ });
48
+
49
+ /**
50
+ * Scope, then owner and organization, for a new entity. `legacyOrgId` (the
51
+ * person's legacy organization pointer) rides along so a later lookup can
52
+ * reuse an entity written before scoping.
53
+ */
54
+ const resolveOwnership = ({ entityType, requestScope, user }) => {
55
+ const ctx = contextFor(user);
56
+ const resolved = resolveEntityScope({ entityType, requestScope, user });
57
+ return {
58
+ ...resolveOwnerForScope(resolved.scope, ctx),
59
+ legacyOrgId: ctx.legacyOrgId,
60
+ source: resolved.source,
61
+ };
62
+ };
63
+
64
+ /** Whether `user` may load this stored entity for use (same clauses as the list). */
65
+ const canUseEntity = (user, entity) =>
66
+ describeRecord(entity, contextFor(user), 'entity') !== null;
67
+
68
+ return Object.freeze({
69
+ individualAcross,
70
+ accessContext: contextFor,
71
+ resolveScope: resolveEntityScope,
72
+ resolveOwnership,
73
+ canUseEntity,
74
+ });
75
+ }
76
+
77
+ /** The policy of an app that configures nothing; what a use case gets by default. */
78
+ const DEFAULT_SCOPE_POLICY = createScopePolicy();
79
+
80
+ module.exports = { createScopePolicy, DEFAULT_SCOPE_POLICY };
@@ -0,0 +1,277 @@
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
+ /** Raised at app-definition load for an invalid ownership setting. */
29
+ class OwnershipConfigError extends BaseError {
30
+ constructor(message, options) {
31
+ super(message, options);
32
+ this.code = 'OWNERSHIP_CONFIG_INVALID';
33
+ }
34
+ }
35
+
36
+ const isPlainObject = (value) =>
37
+ value !== null && typeof value === 'object' && !Array.isArray(value);
38
+
39
+ const configError = (where, message) =>
40
+ new OwnershipConfigError(`${where}: ${message}`);
41
+
42
+ /**
43
+ * One level's setting: a scope (`'organization'`) or a default plus what a
44
+ * request may choose (`{ default, allow }`). Returns `{ default, allow }`, or
45
+ * null when the level sets nothing. Idempotent on its own output.
46
+ */
47
+ function parseScopeSetting(setting, where) {
48
+ if (setting === undefined || setting === null) return null;
49
+ if (typeof setting === 'string') {
50
+ if (!SCOPE_VALUES.includes(setting)) {
51
+ throw configError(
52
+ where,
53
+ `scope '${setting}' is not one of ${SCOPE_VALUES.join(', ')}`
54
+ );
55
+ }
56
+ return { default: setting, allow: [setting] };
57
+ }
58
+ if (!isPlainObject(setting)) {
59
+ throw configError(where, 'scope must be a scope name or { default, allow }');
60
+ }
61
+ const unknown = Object.keys(setting).filter(
62
+ (key) => key !== 'default' && key !== 'allow'
63
+ );
64
+ if (unknown.length) {
65
+ throw configError(where, `unknown scope setting(s): ${unknown.join(', ')}`);
66
+ }
67
+ const { allow, default: defaultScope } = setting;
68
+ if (allow !== undefined) {
69
+ if (
70
+ !Array.isArray(allow) ||
71
+ allow.length === 0 ||
72
+ allow.some((value) => !SCOPE_VALUES.includes(value)) ||
73
+ new Set(allow).size !== allow.length
74
+ ) {
75
+ throw configError(
76
+ where,
77
+ `scope.allow must list one or more of ${SCOPE_VALUES.join(', ')} once each`
78
+ );
79
+ }
80
+ }
81
+ if (defaultScope !== undefined && !SCOPE_VALUES.includes(defaultScope)) {
82
+ throw configError(
83
+ where,
84
+ `scope.default '${defaultScope}' is not one of ${SCOPE_VALUES.join(', ')}`
85
+ );
86
+ }
87
+ if (defaultScope === undefined && (allow === undefined || allow.length !== 1)) {
88
+ throw configError(where, 'scope needs a default (or an allow list of exactly one)');
89
+ }
90
+ const resolvedDefault = defaultScope ?? allow[0];
91
+ const resolvedAllow = allow ?? [resolvedDefault];
92
+ if (!resolvedAllow.includes(resolvedDefault)) {
93
+ throw configError(
94
+ where,
95
+ `scope.default '${resolvedDefault}' must be in scope.allow`
96
+ );
97
+ }
98
+ return { default: resolvedDefault, allow: [...resolvedAllow] };
99
+ }
100
+
101
+ /** The level-6 default: `organization` for an organization-primary app. */
102
+ const defaultScopeFor = (primary) =>
103
+ primary === 'organization' ? SCOPES.ORGANIZATION : SCOPES.INDIVIDUAL;
104
+
105
+ const LEVELS = ['binding', 'integration', 'entityType', 'app'];
106
+
107
+ /**
108
+ * The first level that sets a scope (ADR-056 §3.1), each overriding those
109
+ * below it; nothing intersects. Returns `{ default, allow, source }`.
110
+ */
111
+ function decideLevel({ binding, integration, entityType, app, primary } = {}) {
112
+ const settings = { binding, integration, entityType, app };
113
+ for (const source of LEVELS) {
114
+ const parsed = parseScopeSetting(settings[source], `${source} scope`);
115
+ if (parsed) return { ...parsed, source };
116
+ }
117
+ const fallback = defaultScopeFor(primary);
118
+ return { default: fallback, allow: [fallback], source: 'default' };
119
+ }
120
+
121
+ /**
122
+ * What a request may choose, from the deciding level's `allow` list only. A
123
+ * tenant request is never given `global`; a scope outside the list is refused.
124
+ * Both are the same refusal (403 SCOPE_NOT_ALLOWED): the caller asked for
125
+ * something this app does not offer.
126
+ */
127
+ function applyRequest(decided, request) {
128
+ const result = {
129
+ scope: decided.default,
130
+ source: decided.source,
131
+ decidedBy: decided.source,
132
+ allow: decided.allow,
133
+ };
134
+ if (request === undefined || request === null) return result;
135
+ if (!SCOPE_VALUES.includes(request)) {
136
+ throw Boom.badRequest(
137
+ `scope must be one of ${SCOPE_VALUES.filter((s) => s !== SCOPES.GLOBAL).join(', ')}`,
138
+ { code: 'SCOPE_INVALID', details: { field: 'scope' } }
139
+ );
140
+ }
141
+ if (request === SCOPES.GLOBAL) {
142
+ throw Boom.forbidden(
143
+ 'global entities are created by the app owner through admin routes, not by a tenant request',
144
+ { code: 'SCOPE_NOT_ALLOWED' }
145
+ );
146
+ }
147
+ if (!decided.allow.includes(request)) {
148
+ throw Boom.forbidden(
149
+ `scope '${request}' is not allowed here; allowed: ${decided.allow.join(', ')}`,
150
+ {
151
+ code: 'SCOPE_NOT_ALLOWED',
152
+ details: { field: 'scope', allowed: decided.allow },
153
+ }
154
+ );
155
+ }
156
+ return { ...result, scope: request, source: 'request' };
157
+ }
158
+
159
+ /**
160
+ * The scope a new connection gets: decideLevel then applyRequest.
161
+ *
162
+ * @param {Object} levels
163
+ * @param {string} [levels.request] - `scope` on the request
164
+ * @param {*} [levels.binding] - `use(module, { scope })` / `Definition.entities[key].scope`
165
+ * @param {*} [levels.integration] - `Definition.ownership.scope`
166
+ * @param {*} [levels.entityType] - registry `settings.scope` (ADR-059)
167
+ * @param {*} [levels.app] - `appDefinition.ownership.scope`
168
+ * @param {string} [levels.primary] - `user.primary`
169
+ * @returns {{scope: string, source: string, decidedBy: string, allow: string[]}}
170
+ */
171
+ function resolveScope({ request, ...levels } = {}) {
172
+ return applyRequest(decideLevel(levels), request);
173
+ }
174
+
175
+ const MISSING_INDIVIDUAL =
176
+ 'this request has no individual user; send x-frigg-appuserid (or sign in with a token that names the person)';
177
+ const MISSING_ORGANIZATION =
178
+ 'this request has no active organization; send x-frigg-apporgid (or sign in with a token that names the organization)';
179
+
180
+ const missing = (message, field) =>
181
+ Boom.badRequest(message, { code: 'IDENTITY_REQUIRED', details: { field } });
182
+
183
+ /**
184
+ * The owner and organization for a new entity of `scope` (§3.2):
185
+ *
186
+ * individual -> the person; the active organization (null under 'all')
187
+ * organization -> the active organization's user, in that organization
188
+ * global -> refused here: the tenant API never creates global entities
189
+ *
190
+ * `ctx` is an access context (scope-visibility.accessContext).
191
+ */
192
+ function resolveOwnerForScope(scope, ctx) {
193
+ if (scope === SCOPES.INDIVIDUAL) {
194
+ if (!ctx.callerId) throw missing(MISSING_INDIVIDUAL, 'x-frigg-appuserid');
195
+ return {
196
+ scope,
197
+ ownerId: ctx.callerId,
198
+ organizationId: ctx.individualAcross === 'all' ? null : ctx.activeOrgId,
199
+ };
200
+ }
201
+ if (scope === SCOPES.ORGANIZATION) {
202
+ if (!ctx.activeOrgId) throw missing(MISSING_ORGANIZATION, 'x-frigg-apporgid');
203
+ return { scope, ownerId: ctx.activeOrgId, organizationId: ctx.activeOrgId };
204
+ }
205
+ throw Boom.forbidden(
206
+ 'global entities are created by the app owner through admin routes, not through the tenant API',
207
+ { code: 'SCOPE_NOT_ALLOWED' }
208
+ );
209
+ }
210
+
211
+ /**
212
+ * The owner kind of an integration (§3 "Integration ownership"): the
213
+ * organization when every entity it uses is organization-scoped, otherwise the
214
+ * person, unless `Definition.ownership.owner` says. ResolveIntegrationOwnership
215
+ * then refuses an integration whose entities are not all of that one scope.
216
+ */
217
+ function integrationOwnerKind(entityScopes, rule = 'derive') {
218
+ if (rule === 'individual' || rule === 'organization') return rule;
219
+ return entityScopes.length > 0 &&
220
+ entityScopes.every((scope) => scope === SCOPES.ORGANIZATION)
221
+ ? SCOPES.ORGANIZATION
222
+ : SCOPES.INDIVIDUAL;
223
+ }
224
+
225
+ /**
226
+ * The owner and organization of a new integration of owner kind `kind`
227
+ * ('individual' | 'organization').
228
+ */
229
+ function resolveIntegrationOwner(kind, ctx) {
230
+ if (kind === SCOPES.ORGANIZATION) {
231
+ if (!ctx.activeOrgId) throw missing(MISSING_ORGANIZATION, 'x-frigg-apporgid');
232
+ return { ownerId: ctx.activeOrgId, organizationId: ctx.activeOrgId };
233
+ }
234
+ if (!ctx.callerId) throw missing(MISSING_INDIVIDUAL, 'x-frigg-appuserid');
235
+ return { ownerId: ctx.callerId, organizationId: ctx.activeOrgId };
236
+ }
237
+
238
+ /**
239
+ * The entity an authorization should reuse. The same provider account in two
240
+ * organizations is two entities (§3.6), so an exact organization match wins.
241
+ * A legacy row (organizationId null, written before scoping) is reused when
242
+ * the visibility rule would show it here: an organization-owned one always
243
+ * (the owner is unambiguous), an individual one only in the person's legacy
244
+ * organization.
245
+ */
246
+ function pickExistingEntity(candidates, { scope, organizationId, legacyOrgId }) {
247
+ const rows = candidates || [];
248
+ const unscoped = (row) => !isPresent(row.organizationId);
249
+ const exact = rows.find((row) =>
250
+ isPresent(organizationId)
251
+ ? sameId(row.organizationId, organizationId)
252
+ : unscoped(row)
253
+ );
254
+ if (exact) return exact;
255
+ const legacyApplies =
256
+ scope === SCOPES.ORGANIZATION || sameId(legacyOrgId, organizationId);
257
+ return legacyApplies ? rows.find(unscoped) ?? null : null;
258
+ }
259
+
260
+ module.exports = {
261
+ SCOPES,
262
+ SCOPE_VALUES,
263
+ INDIVIDUAL_ACROSS_VALUES,
264
+ OWNER_RULES,
265
+ ACCESS,
266
+ OwnershipConfigError,
267
+ configError,
268
+ isPlainObject,
269
+ parseScopeSetting,
270
+ decideLevel,
271
+ applyRequest,
272
+ resolveScope,
273
+ resolveOwnerForScope,
274
+ integrationOwnerKind,
275
+ resolveIntegrationOwner,
276
+ pickExistingEntity,
277
+ };
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Which stored rows a caller may see and use (ADR-056 §3.4, §3.6).
3
+ *
4
+ * No I/O. One clause list (`ctx.clauses`) is what a repository is asked for and
5
+ * what `describeRecord` checks every returned row against, so a list and an
6
+ * access check cannot drift apart. The clauses are built once per request, when
7
+ * the access context is.
8
+ */
9
+
10
+ const { isPresent, idString, sameId } = require('../user/membership');
11
+ const { SCOPES, ACCESS } = require('./scope-resolution');
12
+
13
+ /**
14
+ * One owner/organization pair a repository ORs into a single query.
15
+ * `userId` is the owner's user id, and for an organization-owned row that is
16
+ * the ORGANIZATION user's id, not a person's. `organizationId: null` means "has
17
+ * no organization" (unset or null); otherwise the id of the organization.
18
+ *
19
+ * @typedef {Object} OwnershipClause
20
+ * @property {string} userId
21
+ * @property {(string|null)} organizationId
22
+ */
23
+
24
+ /**
25
+ * Whether the caller's own rows with no organization belong to the context this
26
+ * request acts in: always when it acts in no organization (today's behaviour),
27
+ * always for an entity under `individualAcross: 'all'`, and otherwise only in
28
+ * the person's legacy organization.
29
+ */
30
+ function unscopedRowsBelongHere(base, kind) {
31
+ return (
32
+ !base.activeOrgId ||
33
+ (kind === 'entity' && base.individualAcross === 'all') ||
34
+ sameId(base.legacyOrgId, base.activeOrgId)
35
+ );
36
+ }
37
+
38
+ /**
39
+ * What a caller may see, as owner/organization pairs:
40
+ *
41
+ * - the active organization's own rows (owner = the organization), whether
42
+ * they carry its id or, written before scoping, none;
43
+ * - the caller's rows in the active organization;
44
+ * - the caller's rows with no organization, when `unscopedRowsBelongHere`.
45
+ *
46
+ * `kind` is 'entity' or 'integration'; only entities may be
47
+ * organization-independent under 'all' (an integration always belongs to the
48
+ * organization it was created in).
49
+ *
50
+ * @returns {OwnershipClause[]}
51
+ */
52
+ function buildClauses(base, kind) {
53
+ const clauses = [];
54
+ if (base.activeOrgId) {
55
+ clauses.push({ userId: base.activeOrgId, organizationId: base.activeOrgId });
56
+ clauses.push({ userId: base.activeOrgId, organizationId: null });
57
+ }
58
+ if (base.callerId) {
59
+ if (base.activeOrgId) {
60
+ clauses.push({ userId: base.callerId, organizationId: base.activeOrgId });
61
+ }
62
+ if (unscopedRowsBelongHere(base, kind)) {
63
+ clauses.push({ userId: base.callerId, organizationId: null });
64
+ }
65
+ }
66
+ return clauses;
67
+ }
68
+
69
+ /**
70
+ * Who the caller is, for ownership and visibility: their individual user, the
71
+ * organization this request acts in, the legacy `organizationId` pointer, and
72
+ * the clauses that follow from them (computed once).
73
+ *
74
+ * @param {Object} user - the authenticated `User` (or a `{ getId }` double)
75
+ * @param {{individualAcross?: string}} [options]
76
+ */
77
+ function accessContext(user, { individualAcross = 'organization' } = {}) {
78
+ const hasUserModel = typeof user?.getIndividualUser === 'function';
79
+ const individual = hasUserModel ? user.getIndividualUser() : null;
80
+ const base = {
81
+ callerId: hasUserModel ? idString(individual?.id) : idString(user?.getId?.()),
82
+ activeOrgId:
83
+ typeof user?.getActiveOrganizationId === 'function'
84
+ ? idString(user.getActiveOrganizationId())
85
+ : null,
86
+ legacyOrgId: idString(individual?.organizationId),
87
+ individualAcross,
88
+ };
89
+ return {
90
+ ...base,
91
+ clauses: {
92
+ entity: buildClauses(base, 'entity'),
93
+ integration: buildClauses(base, 'integration'),
94
+ },
95
+ };
96
+ }
97
+
98
+ /** The clauses for `kind` ('entity' | 'integration'): what to ask the repository. */
99
+ const visibilityClauses = (ctx, kind) => ctx.clauses[kind];
100
+
101
+ const matchesClause = (record, clause) =>
102
+ sameId(record.userId, clause.userId) &&
103
+ (clause.organizationId === null
104
+ ? !isPresent(record.organizationId)
105
+ : sameId(record.organizationId, clause.organizationId));
106
+
107
+ /** The scope a stored row has for this caller, from its owner. */
108
+ function scopeOf(record, ctx) {
109
+ if (!isPresent(record?.userId)) return SCOPES.GLOBAL;
110
+ if (sameId(record.userId, ctx.activeOrgId)) return SCOPES.ORGANIZATION;
111
+ if (sameId(record.userId, ctx.callerId)) return SCOPES.INDIVIDUAL;
112
+ return null;
113
+ }
114
+
115
+ const accessFor = (scope) =>
116
+ scope === SCOPES.GLOBAL ? ACCESS.USE : ACCESS.MANAGE;
117
+
118
+ /**
119
+ * What a row is to this caller, or null when they may not see it.
120
+ * `{ scope, owner: {type, id}, organizationId, access }` (§3.4). A `global`
121
+ * row is never described: it is not the tenant's to see.
122
+ */
123
+ function describeRecord(record, ctx, kind) {
124
+ if (!record) return null;
125
+ const scope = scopeOf(record, ctx);
126
+ if (scope === null || scope === SCOPES.GLOBAL) return null;
127
+ if (!ctx.clauses[kind].some((clause) => matchesClause(record, clause))) {
128
+ return null;
129
+ }
130
+ return {
131
+ scope,
132
+ owner: { type: scope, id: idString(record.userId) },
133
+ organizationId:
134
+ scope === SCOPES.ORGANIZATION
135
+ ? idString(record.userId)
136
+ : idString(record.organizationId),
137
+ access: accessFor(scope),
138
+ };
139
+ }
140
+
141
+ /** `?owner=` values and the scope each one means. */
142
+ const LIST_OWNERS = Object.freeze({
143
+ me: SCOPES.INDIVIDUAL,
144
+ org: SCOPES.ORGANIZATION,
145
+ });
146
+
147
+ /** Whether a described row passes `?scope=` and `?owner=`. */
148
+ function matchesListFilters(description, { scope, owner } = {}) {
149
+ if (scope !== undefined && description.scope !== scope) return false;
150
+ if (owner !== undefined && description.owner.type !== LIST_OWNERS[owner]) {
151
+ return false;
152
+ }
153
+ return true;
154
+ }
155
+
156
+ module.exports = {
157
+ accessContext,
158
+ unscopedRowsBelongHere,
159
+ visibilityClauses,
160
+ matchesClause,
161
+ scopeOf,
162
+ describeRecord,
163
+ LIST_OWNERS,
164
+ matchesListFilters,
165
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Test double helper: answers an owner/organization clause the way a database
3
+ * does, so in-memory repositories run the clauses a use case hands them.
4
+ * `clause.organizationId === null` matches a row with no organization (unset
5
+ * or null).
6
+ */
7
+ const same = (a, b) =>
8
+ a !== undefined && a !== null && b !== undefined && b !== null && String(a) === String(b);
9
+
10
+ const matchesClause = (row, clause) =>
11
+ same(row.userId, clause.userId) &&
12
+ (clause.organizationId === null
13
+ ? row.organizationId === null || row.organizationId === undefined
14
+ : same(row.organizationId, clause.organizationId));
15
+
16
+ const matchesAnyClause = (row, clauses) => clauses.some((clause) => matchesClause(row, clause));
17
+
18
+ module.exports = { same, matchesClause, matchesAnyClause };