@friggframework/core 2.0.0--canary.681.2df9ef0.0 → 2.0.0--canary.683.f45da33.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 (83) hide show
  1. package/README.md +37 -1
  2. package/application/commands/integration-commands.js +20 -10
  3. package/application/index.js +6 -2
  4. package/credential/use-cases/credential-operations-for-user.js +9 -1
  5. package/credential/use-cases/reauthorize-credential-for-user.js +14 -4
  6. package/database/encryption/encryption-schema-registry.js +7 -0
  7. package/database/organization-where.js +18 -0
  8. package/generated/prisma-mongodb/edge.js +5 -3
  9. package/generated/prisma-mongodb/index-browser.js +2 -0
  10. package/generated/prisma-mongodb/index.d.ts +115 -2
  11. package/generated/prisma-mongodb/index.js +5 -3
  12. package/generated/prisma-mongodb/package.json +1 -1
  13. package/generated/prisma-mongodb/schema.prisma +15 -0
  14. package/generated/prisma-mongodb/wasm.js +5 -3
  15. package/generated/prisma-postgresql/edge.js +5 -3
  16. package/generated/prisma-postgresql/index-browser.js +2 -0
  17. package/generated/prisma-postgresql/index.d.ts +131 -2
  18. package/generated/prisma-postgresql/index.js +5 -3
  19. package/generated/prisma-postgresql/package.json +1 -1
  20. package/generated/prisma-postgresql/schema.prisma +15 -0
  21. package/generated/prisma-postgresql/wasm.js +5 -3
  22. package/handlers/app-definition-loader.js +25 -8
  23. package/handlers/backend-utils.js +31 -25
  24. package/handlers/routers/health.js +1 -4
  25. package/handlers/workers/integration-defined-workers.js +5 -2
  26. package/index.js +10 -2
  27. package/integrations/index.js +0 -4
  28. package/integrations/integration-router.js +101 -52
  29. package/integrations/repositories/integration-not-found.js +14 -0
  30. package/integrations/repositories/integration-repository-documentdb.js +34 -3
  31. package/integrations/repositories/integration-repository-interface.js +17 -1
  32. package/integrations/repositories/integration-repository-mongo.js +59 -76
  33. package/integrations/repositories/integration-repository-postgres.js +62 -76
  34. package/integrations/tests/doubles/test-integration-repository.js +12 -1
  35. package/integrations/use-cases/create-integration.js +55 -20
  36. package/integrations/use-cases/get-integrations-for-user.js +80 -40
  37. package/integrations/use-cases/index.js +5 -1
  38. package/integrations/use-cases/integration-operations-for-user.js +55 -24
  39. package/integrations/use-cases/resolve-integration-ownership.js +177 -0
  40. package/integrations/utils/map-integration-dto.js +1 -15
  41. package/management-api/management-api-config.js +3 -0
  42. package/management-api/openapi/components.js +46 -1
  43. package/management-api/openapi/v2-operations.js +14 -1
  44. package/management-api/v2/dependencies.js +58 -14
  45. package/management-api/v2/dto.js +17 -0
  46. package/management-api/v2/handlers/authorization.js +1 -0
  47. package/management-api/v2/handlers/entities.js +10 -7
  48. package/management-api/v2/handlers/integrations.js +6 -3
  49. package/management-api/v2/request.js +32 -1
  50. package/modules/entity-scope.js +15 -0
  51. package/modules/entity-type-registry.js +311 -0
  52. package/modules/index.js +14 -0
  53. package/modules/repositories/module-repository-documentdb.js +41 -1
  54. package/modules/repositories/module-repository-interface.js +19 -2
  55. package/modules/repositories/module-repository-mongo.js +64 -73
  56. package/modules/repositories/module-repository-postgres.js +63 -73
  57. package/modules/scope-config.js +177 -0
  58. package/modules/scope-policy.js +96 -0
  59. package/modules/scope-resolution.js +288 -0
  60. package/modules/scope-visibility.js +212 -0
  61. package/modules/tests/doubles/ownership-matching.js +18 -0
  62. package/modules/use-cases/authorize-entity-for-user.js +59 -0
  63. package/modules/use-cases/entity-operations-for-user.js +50 -13
  64. package/modules/use-cases/entity-types.js +62 -14
  65. package/modules/use-cases/execute-entity-proxy-request.js +15 -0
  66. package/modules/use-cases/get-entities-for-user.js +16 -5
  67. package/modules/use-cases/get-entity-module-for-user.js +23 -3
  68. package/modules/use-cases/get-entity-options-by-id.js +5 -2
  69. package/modules/use-cases/get-module.js +5 -2
  70. package/modules/use-cases/manage-access.js +21 -0
  71. package/modules/use-cases/multi-step-authorization.js +36 -12
  72. package/modules/use-cases/process-authorization-callback.js +52 -18
  73. package/modules/use-cases/refresh-entity-options.js +5 -2
  74. package/modules/use-cases/test-module-auth.js +5 -2
  75. package/package.json +5 -5
  76. package/prisma-mongodb/schema.prisma +15 -0
  77. package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
  78. package/prisma-postgresql/schema.prisma +15 -0
  79. package/types/index.d.ts +1 -0
  80. package/types/modules/index.d.ts +145 -0
  81. package/types/user/index.d.ts +7 -0
  82. package/user/use-cases/authenticate-user.js +7 -1
  83. package/user/user.js +24 -1
@@ -1,9 +1,12 @@
1
1
  const { Module } = require('../module');
2
+ const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
2
3
 
3
4
  class GetModule {
4
- constructor({ moduleRepository, moduleDefinitions }) {
5
+ constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
5
6
  this.moduleRepository = moduleRepository;
6
7
  this.moduleDefinitions = moduleDefinitions;
8
+ // Who may load an entity follows its scope (ADR-056 §3.6).
9
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
7
10
  }
8
11
 
9
12
  /**
@@ -32,7 +35,7 @@ class GetModule {
32
35
  // If User object provided, use ownsUserId to check linked users
33
36
  // Otherwise fall back to simple equality check
34
37
  const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
35
- ? userIdOrUser.ownsUserId(entity.userId)
38
+ ? this.scopePolicy.canUseEntity(userIdOrUser, entity)
36
39
  : entity.userId?.toString() === userId?.toString();
37
40
 
38
41
  if (!isOwned) {
@@ -0,0 +1,21 @@
1
+ const Boom = require('@hapi/boom');
2
+ const { ACCESS } = require('../scope-resolution');
3
+
4
+ /**
5
+ * The one check behind every "may this caller change or delete it" gate
6
+ * (ADR-056 §3 permissions): the caller may use it but not manage it, so 403
7
+ * `ORG_ADMIN_REQUIRED`. `access` comes from describeRecord (entities,
8
+ * integrations) or scopePolicy.accessToOwnedBy (credentials).
9
+ *
10
+ * @param {string|null} access - ACCESS value
11
+ * @param {string} subject - e.g. `entity 5`, for the message
12
+ */
13
+ function assertCanManage(access, subject) {
14
+ if (access !== ACCESS.MANAGE) {
15
+ throw Boom.forbidden(`You may use ${subject} but not change or delete it`, {
16
+ code: 'ORG_ADMIN_REQUIRED',
17
+ });
18
+ }
19
+ }
20
+
21
+ module.exports = { assertCanManage };
@@ -3,6 +3,8 @@ const { FetchError } = require('../../errors/fetch-error');
3
3
  const { getLogger } = require('../../logs');
4
4
  const { AuthorizationSession } = require('../domain/authorization-session');
5
5
  const { findDefinition, getAuthStepCount } = require('./entity-types');
6
+ const { describeRecord } = require('../scope-visibility');
7
+ const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
6
8
 
7
9
  const log = getLogger('frigg.api.authorize');
8
10
 
@@ -144,21 +146,28 @@ class SubmitAuthorizationStep {
144
146
  moduleDefinitions,
145
147
  getAuthorizationRequirements,
146
148
  authorizationSessionRepository,
147
- processAuthorizationCallback,
149
+ authorizeEntityForUser,
148
150
  moduleRepository,
149
151
  credentialRepository,
150
152
  createModule,
153
+ scopePolicy,
151
154
  }) {
155
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
152
156
  this.moduleDefinitions = moduleDefinitions || [];
153
157
  this.getAuthorizationRequirements = getAuthorizationRequirements;
154
158
  this.sessions = authorizationSessionRepository;
155
- this.processAuthorizationCallback = processAuthorizationCallback;
159
+ this.authorizeEntityForUser = authorizeEntityForUser;
156
160
  this.moduleRepository = moduleRepository;
157
161
  this.credentialRepository = credentialRepository;
158
162
  this.createModule = createModule || defaultCreateModule;
159
163
  }
160
164
 
161
- async execute({ user, entityType, data, step = 1, sessionId, credentialId = null }) {
165
+ /**
166
+ * @param {Object} params
167
+ * @param {string} [params.scope] - the scope the caller asks for, from the
168
+ * deciding level's allow list (ADR-056 §3.1). Never `global`.
169
+ */
170
+ async execute({ user, entityType, data, step = 1, sessionId, credentialId = null, scope }) {
162
171
  if (!data || typeof data !== 'object' || Array.isArray(data)) {
163
172
  throw Boom.badRequest('data must be an object', {
164
173
  code: 'VALIDATION_ERROR',
@@ -166,7 +175,15 @@ class SubmitAuthorizationStep {
166
175
  });
167
176
  }
168
177
  const definition = findDefinition(this.moduleDefinitions, entityType);
178
+ // `userId` binds a session to the caller and is what GET /authorize
179
+ // computes too. The owner of the entity this creates is decided by
180
+ // scope (§3.2), not by user.primary, and is `ownership.ownerId`.
169
181
  const userId = user.getId();
182
+ const ownership = this.authorizeEntityForUser.resolveOwnership({
183
+ user,
184
+ entityType,
185
+ scope,
186
+ });
170
187
 
171
188
  if (!isMultiStep(definition)) {
172
189
  if (step !== 1) {
@@ -174,7 +191,7 @@ class SubmitAuthorizationStep {
174
191
  code: 'INVALID_STEP',
175
192
  });
176
193
  }
177
- return this.complete({ userId, entityType, params: data, credentialId });
194
+ return this.complete({ ownership, user, entityType, params: data, credentialId });
178
195
  }
179
196
 
180
197
  const totalSteps = getAuthStepCount(definition);
@@ -205,7 +222,7 @@ class SubmitAuthorizationStep {
205
222
  );
206
223
  }
207
224
 
208
- const module = this.createModule({ userId, definition });
225
+ const module = this.createModule({ userId: ownership.ownerId, definition });
209
226
  let result;
210
227
  try {
211
228
  result = await definition.processAuthorizationStep(
@@ -224,7 +241,8 @@ class SubmitAuthorizationStep {
224
241
  session.markComplete();
225
242
  await this.sessions.update(session);
226
243
  const outcome = await this.complete({
227
- userId,
244
+ ownership,
245
+ user,
228
246
  entityType,
229
247
  params: result.authData || {},
230
248
  credentialId,
@@ -261,14 +279,15 @@ class SubmitAuthorizationStep {
261
279
  };
262
280
  }
263
281
 
264
- async complete({ userId, entityType, params, credentialId }) {
282
+ async complete({ ownership, user, entityType, params, credentialId }) {
265
283
  let callback;
266
284
  try {
267
- callback = await this.processAuthorizationCallback.execute(
268
- userId,
285
+ ({ result: callback } = await this.authorizeEntityForUser.execute({
286
+ user,
269
287
  entityType,
270
- params
271
- );
288
+ data: params,
289
+ ownership,
290
+ }));
272
291
  } catch (error) {
273
292
  throw toAuthorizationError(error);
274
293
  }
@@ -276,13 +295,18 @@ class SubmitAuthorizationStep {
276
295
  this.moduleRepository.findEntityById(callback.entity_id),
277
296
  this.credentialRepository.findCredentialById(callback.credential_id),
278
297
  ]);
298
+ // What the new entity is to this caller (§3.4), so a client need not list again.
299
+ const tenancy = entity
300
+ ? describeRecord(entity, this.scopePolicy.accessContext(user), 'entity')
301
+ : null;
279
302
  const outcome = {
280
303
  status: 'complete',
281
- entity,
304
+ entity: entity && tenancy ? { ...entity, tenancy } : entity,
282
305
  credential: credential && {
283
306
  ...credential,
284
307
  type: entityType,
285
308
  entityIds: entity ? [String(entity.id)] : [],
309
+ ...(tenancy && { tenancy }),
286
310
  },
287
311
  };
288
312
  if (credentialId && String(callback.credential_id) !== String(credentialId)) {
@@ -1,5 +1,16 @@
1
1
  const { Module } = require('../module');
2
2
  const { ModuleConstants } = require('../ModuleConstants');
3
+ const { pickExistingEntity } = require('../scope-resolution');
4
+
5
+ /**
6
+ * What an entity belongs to when the caller says nothing: the person's own,
7
+ * outside any organization.
8
+ */
9
+ const DEFAULT_TENANCY = Object.freeze({
10
+ scope: 'individual',
11
+ organizationId: null,
12
+ legacyOrgId: null,
13
+ });
3
14
  const { getLogger } = require('../../logs');
4
15
 
5
16
  const log = getLogger('frigg.modules.authorization_callback');
@@ -30,7 +41,17 @@ class ProcessAuthorizationCallback {
30
41
  this.integrationRepository = integrationRepository;
31
42
  }
32
43
 
33
- async execute(userId, entityType, params) {
44
+ /**
45
+ * @param {string} userId - the OWNER of what this creates (ADR-056 §3.2): the
46
+ * person for `individual`, the organization's user for `organization`.
47
+ * Not necessarily the caller; callers resolve it from the scope.
48
+ * @param {string} entityType
49
+ * @param {Object} params - what the module's authorization needs
50
+ * @param {{scope: string, organizationId: (string|null), legacyOrgId: (string|null)}} [tenancy]
51
+ * the entity's scope and organization (§3.6); default: the person's own,
52
+ * with no organization
53
+ */
54
+ async execute(userId, entityType, params, tenancy = DEFAULT_TENANCY) {
34
55
  log.debug('Authorization callback started', {
35
56
  eventName: `${log.name}.started`,
36
57
  userId,
@@ -57,10 +78,7 @@ class ProcessAuthorizationCallback {
57
78
  userId,
58
79
  entityType
59
80
  );
60
- const existingEntity =
61
- existingEntities && existingEntities.length > 0
62
- ? existingEntities[0]
63
- : null;
81
+ const existingEntity = pickExistingEntity(existingEntities, tenancy);
64
82
 
65
83
  if (existingEntity) {
66
84
  log.debug('Existing entity found', {
@@ -156,7 +174,8 @@ class ProcessAuthorizationCallback {
156
174
  const persistedEntity = await this.findOrCreateEntity(
157
175
  entityDetails,
158
176
  entityType,
159
- module.credential.id
177
+ module.credential.id,
178
+ tenancy
160
179
  );
161
180
  log.debug('Authorization callback completed', {
162
181
  eventName: `${log.name}.completed`,
@@ -233,7 +252,12 @@ class ProcessAuthorizationCallback {
233
252
  module.credential = persisted;
234
253
  }
235
254
 
236
- async findOrCreateEntity(entityDetails, moduleName, credentialId) {
255
+ async findOrCreateEntity(
256
+ entityDetails,
257
+ moduleName,
258
+ credentialId,
259
+ tenancy = DEFAULT_TENANCY
260
+ ) {
237
261
  const { identifiers, details } = entityDetails;
238
262
 
239
263
  // Support both 'user' and 'userId' field names from module definitions
@@ -247,11 +271,16 @@ class ProcessAuthorizationCallback {
247
271
  );
248
272
  }
249
273
 
250
- const existingEntity = await this.moduleRepository.findEntity({
251
- externalId: identifiers.externalId,
252
- user: userId,
253
- moduleName: moduleName,
254
- });
274
+ // The same provider account in two organizations is two entities
275
+ // (ADR-056 §3.6), so the match is per organization.
276
+ const existingEntity = pickExistingEntity(
277
+ await this.moduleRepository.findEntities({
278
+ externalId: identifiers.externalId,
279
+ user: userId,
280
+ moduleName: moduleName,
281
+ }),
282
+ tenancy
283
+ );
255
284
 
256
285
  if (existingEntity) {
257
286
  // Repoint the entity's credentialId when re-auth produced a
@@ -282,12 +311,17 @@ class ProcessAuthorizationCallback {
282
311
  return existingEntity;
283
312
  }
284
313
 
285
- return await this.moduleRepository.createEntity({
286
- ...identifiers,
287
- ...details,
288
- moduleName: moduleName,
289
- credential: credentialId,
290
- });
314
+ // `organizationId` is not part of the entity data: that carries provider
315
+ // fields, and one that happens to be called organizationId is theirs.
316
+ return await this.moduleRepository.createEntity(
317
+ {
318
+ ...identifiers,
319
+ ...details,
320
+ moduleName: moduleName,
321
+ credential: credentialId,
322
+ },
323
+ { organizationId: tenancy.organizationId ?? null }
324
+ );
291
325
  }
292
326
  }
293
327
 
@@ -1,4 +1,5 @@
1
1
  const { Module } = require('../module');
2
+ const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
2
3
 
3
4
  class RefreshEntityOptions {
4
5
  /**
@@ -6,9 +7,11 @@ class RefreshEntityOptions {
6
7
  * @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
7
8
  * @param {} params.moduleDefinitions
8
9
  */
9
- constructor({ moduleRepository, moduleDefinitions }) {
10
+ constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
10
11
  this.moduleRepository = moduleRepository;
11
12
  this.moduleDefinitions = moduleDefinitions;
13
+ // Who may load an entity follows its scope (ADR-056 §3.6).
14
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
12
15
  }
13
16
 
14
17
  /**
@@ -36,7 +39,7 @@ class RefreshEntityOptions {
36
39
 
37
40
  // Validate entity ownership
38
41
  const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
39
- ? userIdOrUser.ownsUserId(entity.userId)
42
+ ? this.scopePolicy.canUseEntity(userIdOrUser, entity)
40
43
  : entity.userId?.toString() === userId?.toString();
41
44
 
42
45
  if (!isOwned) {
@@ -1,4 +1,5 @@
1
1
  const { Module } = require('../module');
2
+ const { DEFAULT_SCOPE_POLICY } = require('../scope-policy');
2
3
 
3
4
  class TestModuleAuth {
4
5
  /**
@@ -6,9 +7,11 @@ class TestModuleAuth {
6
7
  * @param {import('../repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository - Repository for module data operations.
7
8
  * @param {Array<Object>} params.moduleDefinitions - Array of module definitions.
8
9
  */
9
- constructor({ moduleRepository, moduleDefinitions }) {
10
+ constructor({ moduleRepository, moduleDefinitions, scopePolicy }) {
10
11
  this.moduleRepository = moduleRepository;
11
12
  this.moduleDefinitions = moduleDefinitions;
13
+ // Who may load an entity follows its scope (ADR-056 §3.6).
14
+ this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
12
15
  }
13
16
 
14
17
  /**
@@ -35,7 +38,7 @@ class TestModuleAuth {
35
38
 
36
39
  // Validate entity ownership
37
40
  const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId
38
- ? userIdOrUser.ownsUserId(entity.userId)
41
+ ? this.scopePolicy.canUseEntity(userIdOrUser, entity)
39
42
  : entity.userId?.toString() === userId?.toString();
40
43
 
41
44
  if (!isOwned) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.681.2df9ef0.0",
4
+ "version": "2.0.0--canary.683.f45da33.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -48,9 +48,9 @@
48
48
  }
49
49
  },
50
50
  "devDependencies": {
51
- "@friggframework/eslint-config": "2.0.0--canary.681.2df9ef0.0",
52
- "@friggframework/prettier-config": "2.0.0--canary.681.2df9ef0.0",
53
- "@friggframework/test": "2.0.0--canary.681.2df9ef0.0",
51
+ "@friggframework/eslint-config": "2.0.0--canary.683.f45da33.0",
52
+ "@friggframework/prettier-config": "2.0.0--canary.683.f45da33.0",
53
+ "@friggframework/test": "2.0.0--canary.683.f45da33.0",
54
54
  "@prisma/client": "^6.19.3",
55
55
  "@types/lodash": "4.17.15",
56
56
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -90,5 +90,5 @@
90
90
  "publishConfig": {
91
91
  "access": "public"
92
92
  },
93
- "gitHead": "2df9ef0d1e98841190c6a8136ff77c886ca9e629"
93
+ "gitHead": "f45da337e62262988666a166b3d721020dae9161"
94
94
  }
@@ -134,6 +134,14 @@ model Entity {
134
134
  moduleName String?
135
135
  externalId String?
136
136
 
137
+ // Organization this entity was connected in (ADR-056 §3.6): the active
138
+ // organization for `individual`, the owner for `organization`, null for
139
+ // `global`. Null on an `individual` entity is also what every row written
140
+ // before this column existed has, and what `individualAcross: 'all'` writes.
141
+ // Deliberately not a relation: a deleted organization must leave its rows
142
+ // invisible, not turn them into "unscoped" ones (SetNull would do that).
143
+ organizationId String? @db.ObjectId
144
+
137
145
  data Json @default("{}")
138
146
 
139
147
  createdAt DateTime @default(now())
@@ -150,6 +158,7 @@ model Entity {
150
158
  associationObjects AssociationObject[]
151
159
 
152
160
  @@index([userId])
161
+ @@index([userId, organizationId])
153
162
  @@index([externalId])
154
163
  @@index([moduleName])
155
164
  @@index([credentialId])
@@ -167,6 +176,11 @@ model Integration {
167
176
  user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
168
177
  status IntegrationStatus @default(IN_CREATION)
169
178
 
179
+ // Organization this integration was created in (ADR-056 §3.6): the active
180
+ // organization at create time. Null for rows that predate the column. Not a
181
+ // relation, for the same reason as Entity.organizationId.
182
+ organizationId String? @db.ObjectId
183
+
170
184
  // Configuration and version
171
185
  config Json? // Integration configuration object
172
186
  version String?
@@ -191,6 +205,7 @@ model Integration {
191
205
  processes Process[]
192
206
 
193
207
  @@index([userId])
208
+ @@index([userId, organizationId])
194
209
  @@index([status])
195
210
  @@map("Integration")
196
211
  }
@@ -0,0 +1,25 @@
1
+ -- Entity and Integration carry the organization they were created in (ADR-056 §3.6).
2
+ -- Nullable, no default and no backfill: a row written before this migration keeps
3
+ -- NULL, and the domain (modules/entity-scope.js) reads NULL as "predates scoping"
4
+ -- (docs/guides/entity-scopes.md). No scope column: the owner's user type says it.
5
+ -- Deliberately no foreign key: a deleted organization must leave its rows
6
+ -- invisible, and ON DELETE SET NULL would turn them into unscoped ones.
7
+ --
8
+ -- Release note: the two CREATE INDEX statements below are plain (not
9
+ -- CONCURRENTLY, which Prisma migrations cannot run inside a transaction). Each
10
+ -- takes a SHARE lock on its table while it builds, so writes to "Entity" and
11
+ -- "Integration" wait for it. Both tables are small in a typical Frigg
12
+ -- deployment; on a very large one, create the indexes by hand first with
13
+ -- CREATE INDEX CONCURRENTLY IF NOT EXISTS under the same names, then deploy.
14
+
15
+ -- AlterTable
16
+ ALTER TABLE "Entity" ADD COLUMN "organizationId" INTEGER;
17
+
18
+ -- AlterTable
19
+ ALTER TABLE "Integration" ADD COLUMN "organizationId" INTEGER;
20
+
21
+ -- CreateIndex
22
+ CREATE INDEX "Entity_userId_organizationId_idx" ON "Entity"("userId", "organizationId");
23
+
24
+ -- CreateIndex
25
+ CREATE INDEX "Integration_userId_organizationId_idx" ON "Integration"("userId", "organizationId");
@@ -131,6 +131,14 @@ model Entity {
131
131
  moduleName String?
132
132
  externalId String?
133
133
 
134
+ // Organization this entity was connected in (ADR-056 §3.6): the active
135
+ // organization for `individual`, the owner for `organization`, null for
136
+ // `global`. Null on an `individual` entity is also what every row written
137
+ // before this column existed has, and what `individualAcross: 'all'` writes.
138
+ // Deliberately not a relation: a deleted organization must leave its rows
139
+ // invisible, not turn them into "unscoped" ones (SetNull would do that).
140
+ organizationId Int?
141
+
134
142
  data Json @default("{}")
135
143
 
136
144
  createdAt DateTime @default(now())
@@ -144,6 +152,7 @@ model Entity {
144
152
  associationObjects AssociationObject[]
145
153
 
146
154
  @@index([userId])
155
+ @@index([userId, organizationId])
147
156
  @@index([externalId])
148
157
  @@index([moduleName])
149
158
  @@index([credentialId])
@@ -160,6 +169,11 @@ model Integration {
160
169
  user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
161
170
  status IntegrationStatus @default(IN_CREATION)
162
171
 
172
+ // Organization this integration was created in (ADR-056 §3.6): the active
173
+ // organization at create time. Null for rows that predate the column. Not a
174
+ // relation, for the same reason as Entity.organizationId.
175
+ organizationId Int?
176
+
163
177
  // Configuration and version
164
178
  config Json? // Integration configuration object
165
179
  version String?
@@ -183,6 +197,7 @@ model Integration {
183
197
  processes Process[]
184
198
 
185
199
  @@index([userId])
200
+ @@index([userId, organizationId])
186
201
  @@index([status])
187
202
  }
188
203
 
package/types/index.d.ts CHANGED
@@ -13,3 +13,4 @@
13
13
  /// <reference path="./associations/index.d.ts" />
14
14
  /// <reference path="./syncs/index.d.ts" />
15
15
  /// <reference path="./user/index.d.ts" />
16
+ /// <reference path="./modules/index.d.ts" />
@@ -0,0 +1,145 @@
1
+ declare module "@friggframework/core" {
2
+ /** Where an entity type comes from: the app's `entities` map or an integration binding it. */
3
+ export type EntityTypeSource =
4
+ | { kind: "app" }
5
+ | { kind: "integration"; name: string };
6
+
7
+ export type EntityScope = "individual" | "organization" | "global";
8
+
9
+ /** App-level settings carried from the `entities` entry; empty for an inferred-only type. */
10
+ export interface EntityTypeSettings {
11
+ scope?: EntityScope | { default?: EntityScope; allow?: EntityScope[] };
12
+ proxy?: Record<string, unknown>;
13
+ display?: Record<string, unknown>;
14
+ }
15
+
16
+ export interface EntityTypeEntry {
17
+ /** The API module definition. */
18
+ definition: any;
19
+ /** Frozen. `{ kind: 'app' }` first when registered, then each binding integration. */
20
+ sources: readonly EntityTypeSource[];
21
+ /** Frozen. */
22
+ settings: Readonly<EntityTypeSettings>;
23
+ }
24
+
25
+ /** Entity type name (the definition's moduleName) to its entry. */
26
+ export type EntityTypeRegistry = Map<string, EntityTypeEntry>;
27
+
28
+ /** One `appDefinition.entities` entry. The key must equal the definition's moduleName (and getName() when defined). */
29
+ export interface EntityRegistration extends EntityTypeSettings {
30
+ definition: any;
31
+ }
32
+
33
+ export interface AppDefinitionEntities {
34
+ /** false turns inference off: every module an integration binds must be registered. Default true. */
35
+ $infer?: boolean;
36
+ [entityType: string]: EntityRegistration | boolean | undefined;
37
+ }
38
+
39
+ /**
40
+ * One level's setting: a scope, or a default plus the scopes a request may
41
+ * choose from (ADR-056 §3.1). `global` is never chosen by a tenant request.
42
+ */
43
+ export type ScopeSetting =
44
+ | EntityScope
45
+ | { default?: EntityScope; allow?: EntityScope[] };
46
+
47
+ /**
48
+ * `appDefinition.ownership` (ADR-056 §3, §3.6). Resolution order for a new
49
+ * connection: request, binding, integration, entity type (`entities[name].scope`),
50
+ * this `scope`, then `organization` for an organization-primary app and
51
+ * `individual` otherwise.
52
+ */
53
+ export interface AppOwnership {
54
+ scope?: ScopeSetting;
55
+ /**
56
+ * `'organization'` (default): an individual entity belongs to the organization
57
+ * it was connected in. `'all'`: one connection per person across every
58
+ * organization, stored with no `organizationId`.
59
+ */
60
+ individualAcross?: "organization" | "all";
61
+ }
62
+
63
+ /** `Definition.ownership` on an integration (ADR-056 §3.1, §3.3). */
64
+ export interface IntegrationOwnership {
65
+ /** Scope every binding of this integration accepts unless it sets its own. */
66
+ scope?: ScopeSetting;
67
+ /** `'derive'` (default): organization when every entity it uses is organization-scoped. */
68
+ owner?: "derive" | "individual" | "organization";
69
+ }
70
+
71
+ export interface AppDefinition {
72
+ integrations: any[];
73
+ /** App-level entity type registry (ADR-059). */
74
+ entities?: AppDefinitionEntities;
75
+ /** Who owns what a person connects (ADR-056). */
76
+ ownership?: AppOwnership;
77
+ [key: string]: unknown;
78
+ }
79
+
80
+ /** Whose a record is and what the caller may do with it (ADR-056 §3.4). */
81
+ export interface RecordTenancy {
82
+ scope: Exclude<EntityScope, "global">;
83
+ owner: { type: Exclude<EntityScope, "global">; id: string };
84
+ /** Null for an individual entity connected outside an organization, or under `individualAcross: 'all'`. */
85
+ organizationId: string | null;
86
+ /** `manage`: create, reauthorize, delete. `use`: bind it, proxy through it. `view`: metadata only. */
87
+ access: "manage" | "use" | "view";
88
+ }
89
+
90
+ /** Which scope a connection gets, and from which level of the order (ADR-056 §3.1). */
91
+ export interface ResolvedScope {
92
+ scope: EntityScope;
93
+ /** Where `scope` came from. `request` when the request chose within the allowed list. */
94
+ source: "request" | "binding" | "integration" | "entityType" | "app" | "default";
95
+ /** The level that decided what a request may choose from. */
96
+ decidedBy: "binding" | "integration" | "entityType" | "app" | "default";
97
+ allow: EntityScope[];
98
+ }
99
+
100
+ /**
101
+ * First match wins, each level overriding the ones below it. A request may
102
+ * choose only from the deciding level's `allow`; `global` never comes from a
103
+ * tenant request. Throws a Boom 400/403 for a request scope that is not allowed.
104
+ */
105
+ export function resolveScope(levels: {
106
+ request?: EntityScope;
107
+ binding?: ScopeSetting;
108
+ integration?: ScopeSetting;
109
+ entityType?: ScopeSetting;
110
+ app?: ScopeSetting;
111
+ primary?: "individual" | "organization";
112
+ }): ResolvedScope;
113
+
114
+ /** Every binding of every integration with its resolved scope (for tooling that wants to show the chain). */
115
+ export function listBindingScopes(
116
+ appDefinition: Pick<AppDefinition, "integrations" | "ownership">,
117
+ entityTypeRegistry: EntityTypeRegistry,
118
+ primary?: "individual" | "organization"
119
+ ): Array<{
120
+ integration: string;
121
+ binding: string;
122
+ moduleName: string | null;
123
+ scope: EntityScope;
124
+ source: ResolvedScope["source"];
125
+ owner: "derive" | "individual" | "organization";
126
+ }>;
127
+
128
+ /** Invalid ownership or scope setting, thrown at app-definition load. `code` is always `OWNERSHIP_CONFIG_INVALID`. */
129
+ export class OwnershipConfigError extends Error {
130
+ code: "OWNERSHIP_CONFIG_INVALID";
131
+ }
132
+
133
+ /**
134
+ * Builds the registry from `entities` plus the modules integrations bind.
135
+ * Throws EntityRegistryError on an invalid registry.
136
+ */
137
+ export function getEntityTypeRegistry(
138
+ appDefinition?: Pick<AppDefinition, "entities" | "integrations">
139
+ ): EntityTypeRegistry;
140
+
141
+ /** Invalid `entities` registry. `code` is always `ENTITY_REGISTRY_INVALID`. */
142
+ export class EntityRegistryError extends Error {
143
+ code: "ENTITY_REGISTRY_INVALID";
144
+ }
145
+ }
@@ -62,6 +62,13 @@ declare module "@friggframework/core/user" {
62
62
  * at startup. `strictMembership` wins when both are set.
63
63
  */
64
64
  strictUserValidation?: boolean;
65
+ /**
66
+ * Roles (the adopter token's role claim) that may manage organization-level
67
+ * things (ADR-056 §3). Default `['ADMIN']`; a token with no role claim is
68
+ * `MEMBER`, never admin; a shared-secret (`x-frigg` header) caller needs no
69
+ * role. Compared case-insensitively. Validated at load.
70
+ */
71
+ orgAdminRoles?: string[];
65
72
  authModes?: UserAuthModes;
66
73
  jwtConfig?: AdopterJwtConfig;
67
74
  }