@friggframework/core 2.0.0--canary.684.0da024b.0 → 2.0.0--canary.679.a2bcc2f.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/CLAUDE.md +0 -2
- package/README.md +0 -105
- package/application/commands/integration-commands.js +2 -2
- package/application/commands/user-commands.js +5 -17
- package/credential/use-cases/credential-operations-for-user.js +25 -110
- package/credential/use-cases/reauthorize-credential-for-user.js +8 -15
- package/database/documentdb-utils.js +1 -6
- package/generated/prisma-mongodb/edge.js +4 -15
- package/generated/prisma-mongodb/index-browser.js +1 -12
- package/generated/prisma-mongodb/index.d.ts +86 -2027
- package/generated/prisma-mongodb/index.js +4 -15
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +0 -47
- package/generated/prisma-mongodb/wasm.js +4 -15
- package/generated/prisma-postgresql/edge.js +4 -15
- package/generated/prisma-postgresql/index-browser.js +1 -12
- package/generated/prisma-postgresql/index.d.ts +63 -2162
- package/generated/prisma-postgresql/index.js +4 -15
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +0 -44
- package/generated/prisma-postgresql/wasm.js +4 -15
- package/handlers/app-definition-loader.js +1 -15
- package/index.js +0 -18
- package/integrations/integration-router.js +73 -115
- package/integrations/repositories/integration-repository-documentdb.js +3 -35
- package/integrations/repositories/integration-repository-interface.js +1 -17
- package/integrations/repositories/integration-repository-mongo.js +76 -59
- package/integrations/repositories/integration-repository-postgres.js +76 -62
- package/integrations/tests/doubles/test-integration-repository.js +1 -12
- package/integrations/use-cases/create-integration.js +20 -55
- package/integrations/use-cases/get-integrations-for-user.js +40 -80
- package/integrations/use-cases/index.js +1 -5
- package/integrations/use-cases/integration-operations-for-user.js +24 -55
- package/management-api/management-api-config.js +0 -3
- package/management-api/openapi/components.js +0 -40
- package/management-api/openapi/v2-operations.js +1 -13
- package/management-api/v2/dependencies.js +15 -40
- package/management-api/v2/dto.js +0 -20
- package/management-api/v2/handlers/authorization.js +0 -1
- package/management-api/v2/handlers/credentials.js +2 -6
- package/management-api/v2/handlers/entities.js +7 -10
- package/management-api/v2/handlers/integrations.js +3 -6
- package/management-api/v2/request.js +1 -32
- package/modules/index.js +0 -8
- package/modules/repositories/module-repository-documentdb.js +4 -48
- package/modules/repositories/module-repository-interface.js +2 -19
- package/modules/repositories/module-repository-mongo.js +73 -65
- package/modules/repositories/module-repository-postgres.js +73 -64
- package/modules/use-cases/entity-operations-for-user.js +13 -50
- package/modules/use-cases/entity-types.js +7 -31
- package/modules/use-cases/execute-entity-proxy-request.js +0 -15
- package/modules/use-cases/get-entities-for-user.js +5 -16
- package/modules/use-cases/get-entity-module-for-user.js +3 -23
- package/modules/use-cases/get-entity-options-by-id.js +3 -9
- package/modules/use-cases/get-module.js +3 -9
- package/modules/use-cases/multi-step-authorization.js +12 -39
- package/modules/use-cases/process-authorization-callback.js +18 -52
- package/modules/use-cases/refresh-entity-options.js +3 -9
- package/modules/use-cases/test-module-auth.js +3 -9
- package/package.json +5 -6
- package/prisma-mongodb/schema.prisma +0 -47
- package/prisma-postgresql/schema.prisma +0 -44
- package/types/index.d.ts +0 -1
- package/types/modules/index.d.ts +0 -87
- package/user/repositories/user-repository-documentdb.js +2 -27
- package/user/repositories/user-repository-interface.js +0 -18
- package/user/repositories/user-repository-mongo.js +0 -24
- package/user/repositories/user-repository-postgres.js +0 -18
- package/user/use-cases/authenticate-user.js +1 -7
- package/user/use-cases/get-user-from-adopter-jwt.js +124 -95
- package/user/use-cases/get-user-from-x-frigg-headers.js +80 -83
- package/user/user.js +1 -64
- package/database/organization-where.js +0 -18
- package/database/unique-violation.js +0 -25
- package/integrations/repositories/integration-not-found.js +0 -14
- package/integrations/use-cases/resolve-integration-ownership.js +0 -177
- package/modules/entity-scope.js +0 -15
- package/modules/scope-config.js +0 -192
- package/modules/scope-policy.js +0 -112
- package/modules/scope-resolution.js +0 -288
- package/modules/scope-visibility.js +0 -239
- package/modules/tests/doubles/ownership-matching.js +0 -18
- package/modules/use-cases/authorize-entity-for-user.js +0 -61
- package/modules/use-cases/create-entity-from-credential.js +0 -113
- package/modules/use-cases/manage-access.js +0 -21
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +0 -26
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +0 -25
- package/types/user/index.d.ts +0 -134
- package/user/adopter-jwt-config.js +0 -177
- package/user/adopter-jwt-errors.js +0 -59
- package/user/adopter-token-verifier.js +0 -122
- package/user/app-user-helpers.js +0 -97
- package/user/create-user-authentication.js +0 -64
- package/user/membership.js +0 -51
- package/user/repositories/organization-membership-record.js +0 -19
- package/user/repositories/organization-membership-repository-documentdb.js +0 -106
- package/user/repositories/organization-membership-repository-factory.js +0 -42
- package/user/repositories/organization-membership-repository-interface.js +0 -57
- package/user/repositories/organization-membership-repository-mongo.js +0 -13
- package/user/repositories/organization-membership-repository-postgres.js +0 -29
- package/user/repositories/organization-membership-repository-prisma.js +0 -81
- package/user/tests/doubles/adopter-jwt-fixtures.js +0 -220
- package/user/use-cases/ensure-organization-membership.js +0 -92
- package/user/use-cases/find-individual-users-by-organization.js +0 -54
package/user/user.js
CHANGED
|
@@ -1,20 +1,5 @@
|
|
|
1
1
|
const bcrypt = require('bcryptjs');
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* How the request that produced a `User` proved who it is. Set by
|
|
5
|
-
* AuthenticateUser, never inferred from a missing value. Organization roles
|
|
6
|
-
* (ADR-056 §3, §3.6) depend on it: a shared-secret caller is the app's own
|
|
7
|
-
* backend and is trusted to manage; a Frigg bearer token keeps its pre-roles
|
|
8
|
-
* behaviour unless the app opts in (`user.orgAdminGateBearerTokens`); an
|
|
9
|
-
* adopter token has the role it carries, else the stored membership role,
|
|
10
|
-
* else `MEMBER`.
|
|
11
|
-
*/
|
|
12
|
-
const AUTH_SOURCES = Object.freeze({
|
|
13
|
-
SHARED_SECRET: 'shared-secret',
|
|
14
|
-
ADOPTER_JWT: 'adopter-jwt',
|
|
15
|
-
BEARER_TOKEN: 'bearer-token',
|
|
16
|
-
});
|
|
17
|
-
|
|
18
3
|
/**
|
|
19
4
|
* Represents a user in the system. The User class is a domain entity,
|
|
20
5
|
* @class User
|
|
@@ -33,14 +18,6 @@ class User {
|
|
|
33
18
|
this.individualUser = individualUser;
|
|
34
19
|
this.organizationUser = organizationUser;
|
|
35
20
|
this.usePassword = usePassword;
|
|
36
|
-
// Organization role from an adopter token claim (ADR-056 §2), per
|
|
37
|
-
// request. When present it wins over the stored membership role.
|
|
38
|
-
this.orgRole = null;
|
|
39
|
-
// The role stored on the person's membership of the active organization
|
|
40
|
-
// (OrganizationMembership.orgRole); used when the token carries none.
|
|
41
|
-
this.membershipRole = null;
|
|
42
|
-
// How this request authenticated (one of AUTH_SOURCES); null until AuthenticateUser says.
|
|
43
|
-
this.authSource = null;
|
|
44
21
|
|
|
45
22
|
this.config = {
|
|
46
23
|
primary,
|
|
@@ -49,17 +26,6 @@ class User {
|
|
|
49
26
|
};
|
|
50
27
|
}
|
|
51
28
|
|
|
52
|
-
/**
|
|
53
|
-
* The organization this request acts in (ADR-056 §3.6): the one the token or
|
|
54
|
-
* `x-frigg-appOrgId` named, not the person's legacy `organizationId`
|
|
55
|
-
* pointer. Null when the request has no organization.
|
|
56
|
-
* @returns {string|null}
|
|
57
|
-
*/
|
|
58
|
-
getActiveOrganizationId() {
|
|
59
|
-
const id = this.organizationUser?.id;
|
|
60
|
-
return id === undefined || id === null || id === '' ? null : String(id);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
29
|
getPrimaryUser() {
|
|
64
30
|
if (this.config.primary === 'organization') {
|
|
65
31
|
return this.organizationUser;
|
|
@@ -67,11 +33,6 @@ class User {
|
|
|
67
33
|
return this.individualUser;
|
|
68
34
|
}
|
|
69
35
|
|
|
70
|
-
/**
|
|
71
|
-
* The id of the primary user. With `primary: 'organization'` this is the
|
|
72
|
-
* active organization (the one the request named), never the organization
|
|
73
|
-
* the person was first linked to.
|
|
74
|
-
*/
|
|
75
36
|
getId() {
|
|
76
37
|
return this.getPrimaryUser()?.id;
|
|
77
38
|
}
|
|
@@ -104,30 +65,6 @@ class User {
|
|
|
104
65
|
return this.config.individualUserRequired;
|
|
105
66
|
}
|
|
106
67
|
|
|
107
|
-
getAuthSource() {
|
|
108
|
-
return this.authSource;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
setAuthSource(authSource) {
|
|
112
|
-
this.authSource = authSource ?? null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
getOrgRole() {
|
|
116
|
-
return this.orgRole;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
setOrgRole(orgRole) {
|
|
120
|
-
this.orgRole = orgRole ?? null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
getMembershipRole() {
|
|
124
|
-
return this.membershipRole;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
setMembershipRole(membershipRole) {
|
|
128
|
-
this.membershipRole = membershipRole ?? null;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
68
|
getIndividualUser() {
|
|
132
69
|
return this.individualUser;
|
|
133
70
|
}
|
|
@@ -185,4 +122,4 @@ class User {
|
|
|
185
122
|
}
|
|
186
123
|
}
|
|
187
124
|
|
|
188
|
-
module.exports = { User
|
|
125
|
+
module.exports = { User };
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Prisma `where` fragment for an `organizationId` on MongoDB (ADR-056
|
|
3
|
-
* §3.6), shared by the entity and integration adapters.
|
|
4
|
-
*
|
|
5
|
-
* Prisma on MongoDB: `null` matches only an explicit null and `isSet: false`
|
|
6
|
-
* only a missing field. A row written before the column existed is the common
|
|
7
|
-
* "no organization" case, so `null` here means both.
|
|
8
|
-
*/
|
|
9
|
-
function organizationWhere(organizationId) {
|
|
10
|
-
if (organizationId === null) {
|
|
11
|
-
return {
|
|
12
|
-
OR: [{ organizationId: null }, { organizationId: { isSet: false } }],
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
return { organizationId };
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
module.exports = { organizationWhere };
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/** Mongo/DocumentDB duplicate-key error code. */
|
|
2
|
-
const DUPLICATE_KEY_CODE = 11000;
|
|
3
|
-
/** Prisma's unique-constraint error code (PostgreSQL and MongoDB). */
|
|
4
|
-
const PRISMA_UNIQUE_CODE = 'P2002';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Whether `error` is a unique-constraint violation, however the database
|
|
8
|
-
* layer raised it: a Prisma `P2002`, or a duplicate-key `11000`
|
|
9
|
-
* (`documentdb-utils` `insertOne` sets `code` on the error it throws).
|
|
10
|
-
* Matches on the code only, never on message text.
|
|
11
|
-
*
|
|
12
|
-
* @param {unknown} error
|
|
13
|
-
* @returns {boolean}
|
|
14
|
-
*/
|
|
15
|
-
function isUniqueViolation(error) {
|
|
16
|
-
return (
|
|
17
|
-
error?.code === PRISMA_UNIQUE_CODE || error?.code === DUPLICATE_KEY_CODE
|
|
18
|
-
);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
module.exports = {
|
|
22
|
-
isUniqueViolation,
|
|
23
|
-
DUPLICATE_KEY_CODE,
|
|
24
|
-
PRISMA_UNIQUE_CODE,
|
|
25
|
-
};
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The error `findIntegrationById` throws for an id with no row, and the one
|
|
3
|
-
* predicate that recognises it. The adapters build the error here and the
|
|
4
|
-
* use cases match it here, so the message is written once and a missing
|
|
5
|
-
* integration can be told from any other failure without widening a regex.
|
|
6
|
-
*/
|
|
7
|
-
const MESSAGE = /^Integration with id .+ not found$/;
|
|
8
|
-
|
|
9
|
-
const integrationNotFoundError = (id) =>
|
|
10
|
-
new Error(`Integration with id ${id} not found`);
|
|
11
|
-
|
|
12
|
-
const isIntegrationNotFound = (error) => MESSAGE.test(error?.message || '');
|
|
13
|
-
|
|
14
|
-
module.exports = { integrationNotFoundError, isIntegrationNotFound };
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
const Boom = require('@hapi/boom');
|
|
2
|
-
const { SCOPES, resolveScope } = require('../../modules/scope-resolution');
|
|
3
|
-
const {
|
|
4
|
-
integrationOwnerKind,
|
|
5
|
-
resolveIntegrationOwner,
|
|
6
|
-
} = require('../../modules/scope-resolution');
|
|
7
|
-
const {
|
|
8
|
-
describeRecord,
|
|
9
|
-
unscopedRowsBelongHere,
|
|
10
|
-
} = require('../../modules/scope-visibility');
|
|
11
|
-
const {
|
|
12
|
-
bindingScopeSetting,
|
|
13
|
-
readIntegrationOwnership,
|
|
14
|
-
} = require('../../modules/scope-config');
|
|
15
|
-
const { DEFAULT_SCOPE_POLICY } = require('../../modules/scope-policy');
|
|
16
|
-
const { entityTypeNameOf } = require('../../modules/entity-type-registry');
|
|
17
|
-
|
|
18
|
-
const notFound = (entityId) =>
|
|
19
|
-
Boom.notFound(`Entity ${entityId} not found`, { code: 'ENTITY_NOT_FOUND' });
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Who owns an integration about to be created, and in which organization
|
|
23
|
-
* (ADR-056 §3, §3.3, §3.6). Shared by the v1 and v2 create paths. Nothing is
|
|
24
|
-
* persisted before this has answered, so a refusal leaves no row behind.
|
|
25
|
-
*
|
|
26
|
-
* Every entity must be one the caller may use in the active organization. A
|
|
27
|
-
* missing one, another organization's and a `global` one are the same 404, so
|
|
28
|
-
* ids cannot be probed. When the integration declares a scope (a binding's
|
|
29
|
-
* `scope` or `Definition.entities[key].scope`, or `Definition.ownership.scope`)
|
|
30
|
-
* the entity's scope must be in it. Levels below the integration (entity type,
|
|
31
|
-
* app, default) decide where a connection is CREATED and do not restrict what
|
|
32
|
-
* an integration may bind, so an app that adds a registry scope later does not
|
|
33
|
-
* break integrations that already use older connections.
|
|
34
|
-
*
|
|
35
|
-
* The owner follows the entities (all organization: the organization;
|
|
36
|
-
* otherwise the person) unless `Definition.ownership.owner` says. Every entity
|
|
37
|
-
* must then be of the owner's own scope: an integration is loaded by its owner,
|
|
38
|
-
* who must own what it uses, so an integration that mixes an organization
|
|
39
|
-
* entity with a person's own, or that names an owner that does not match, is
|
|
40
|
-
* refused with a 400 here, before anything is written.
|
|
41
|
-
*/
|
|
42
|
-
class ResolveIntegrationOwnership {
|
|
43
|
-
/**
|
|
44
|
-
* @param {Object} params
|
|
45
|
-
* @param {import('../../modules/repositories/module-repository-interface').ModuleRepositoryInterface} params.moduleRepository
|
|
46
|
-
* @param {Array<Function>} params.integrationClasses
|
|
47
|
-
* @param {Object} [params.scopePolicy] - from createScopePolicy
|
|
48
|
-
*/
|
|
49
|
-
constructor({ moduleRepository, integrationClasses, scopePolicy }) {
|
|
50
|
-
this.moduleRepository = moduleRepository;
|
|
51
|
-
this.integrationClasses = integrationClasses || [];
|
|
52
|
-
this.scopePolicy = scopePolicy || DEFAULT_SCOPE_POLICY;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* @param {Object} params
|
|
57
|
-
* @param {Array<string|number>} params.entityIds
|
|
58
|
-
* @param {Object} params.config - `{ type }`
|
|
59
|
-
* @param {Object} params.user - authenticated User
|
|
60
|
-
* @returns {Promise<{ownerId: string, organizationId: (string|null), ownerKind: string, adoptUnscoped: boolean}>}
|
|
61
|
-
*/
|
|
62
|
-
async execute({ entityIds, config, user }) {
|
|
63
|
-
const ctx = this.scopePolicy.accessContext(user);
|
|
64
|
-
const integrationClass = this.integrationClasses.find(
|
|
65
|
-
(IntegrationClass) => IntegrationClass.Definition?.name === config?.type
|
|
66
|
-
);
|
|
67
|
-
const ownership = readIntegrationOwnership(integrationClass);
|
|
68
|
-
|
|
69
|
-
const entities = await this.loadEntities(entityIds);
|
|
70
|
-
const scoped = entities.map((entity) => ({
|
|
71
|
-
entity,
|
|
72
|
-
scope: this.acceptedScope({ entity, ctx, integrationClass, ownership }),
|
|
73
|
-
}));
|
|
74
|
-
|
|
75
|
-
const ownerKind = integrationOwnerKind(
|
|
76
|
-
scoped.map(({ scope }) => scope),
|
|
77
|
-
ownership.owner
|
|
78
|
-
);
|
|
79
|
-
this.assertOneScope(scoped, ownerKind, config?.type);
|
|
80
|
-
return {
|
|
81
|
-
...resolveIntegrationOwner(ownerKind, ctx),
|
|
82
|
-
ownerKind,
|
|
83
|
-
// Whether an existing integration with no organization (written
|
|
84
|
-
// before scoping) is this caller's to reuse. An organization-owned
|
|
85
|
-
// one is unambiguous: its owner is the organization, whoever asks
|
|
86
|
-
// and whatever their own legacy pointer says. A person's is theirs
|
|
87
|
-
// only where scope-visibility says (their legacy organization).
|
|
88
|
-
adoptUnscoped:
|
|
89
|
-
ownerKind === SCOPES.ORGANIZATION ||
|
|
90
|
-
unscopedRowsBelongHere(ctx, 'integration'),
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Every entity must be of the integration owner's own scope (see the class comment). */
|
|
95
|
-
assertOneScope(scoped, ownerKind, type) {
|
|
96
|
-
for (const { entity, scope } of scoped) {
|
|
97
|
-
if (scope === ownerKind) continue;
|
|
98
|
-
// The load-time check (resolveOwnershipConfig) only sees declared
|
|
99
|
-
// scopes; this one sees the entities actually named.
|
|
100
|
-
if (ownerKind === SCOPES.ORGANIZATION) {
|
|
101
|
-
throw Boom.badRequest(
|
|
102
|
-
"An organization-owned integration cannot use an 'individual' entity: a shared integration must not run on one person's credential",
|
|
103
|
-
{
|
|
104
|
-
code: 'ORG_INTEGRATION_INDIVIDUAL_ENTITY',
|
|
105
|
-
details: { entityId: String(entity.id) },
|
|
106
|
-
}
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
throw Boom.badRequest(
|
|
110
|
-
`Integration '${type}' would be owned by the person but uses the organization entity ${entity.id}. An integration must use entities of one scope: all of the person's, or all of the organization's.`,
|
|
111
|
-
{
|
|
112
|
-
code: 'MIXED_ENTITY_SCOPES',
|
|
113
|
-
details: { entityId: String(entity.id), scope },
|
|
114
|
-
}
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async loadEntities(entityIds) {
|
|
120
|
-
const ids = (entityIds || []).map(String);
|
|
121
|
-
if (ids.length === 0) return [];
|
|
122
|
-
let found;
|
|
123
|
-
try {
|
|
124
|
-
found = await this.moduleRepository.findEntitiesByIds(ids);
|
|
125
|
-
} catch (error) {
|
|
126
|
-
// A malformed id (e.g. not an integer on PostgreSQL) is "not found".
|
|
127
|
-
if (/Invalid ID|ObjectId|Malformed/i.test(error?.message || '')) {
|
|
128
|
-
throw notFound(ids[0]);
|
|
129
|
-
}
|
|
130
|
-
throw error;
|
|
131
|
-
}
|
|
132
|
-
const byId = new Map((found || []).filter(Boolean).map((e) => [String(e.id), e]));
|
|
133
|
-
return ids.map((id) => {
|
|
134
|
-
if (!byId.has(id)) throw notFound(id);
|
|
135
|
-
return byId.get(id);
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** The entity's scope for this caller, or a refusal. */
|
|
140
|
-
acceptedScope({ entity, ctx, integrationClass, ownership }) {
|
|
141
|
-
const described = describeRecord(entity, ctx, 'entity');
|
|
142
|
-
if (!described) throw notFound(entity.id);
|
|
143
|
-
const { scope } = described;
|
|
144
|
-
|
|
145
|
-
const declared = this.declaredScope({ entity, integrationClass, ownership });
|
|
146
|
-
if (declared && !declared.allow.includes(scope)) {
|
|
147
|
-
throw Boom.badRequest(
|
|
148
|
-
`Entity ${entity.id} is ${scope}-scoped, but this integration binds ${entity.moduleName} at ${declared.allow.join(' or ')} scope`,
|
|
149
|
-
{
|
|
150
|
-
code: 'ENTITY_SCOPE_MISMATCH',
|
|
151
|
-
details: { entityId: String(entity.id), scope, allowed: declared.allow },
|
|
152
|
-
}
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
return scope;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* What the integration itself declares for this entity's binding (levels 2
|
|
160
|
-
* and 3 only), or null when it declares nothing.
|
|
161
|
-
*/
|
|
162
|
-
declaredScope({ entity, integrationClass, ownership }) {
|
|
163
|
-
const Definition = integrationClass?.Definition;
|
|
164
|
-
const key = Object.keys(Definition?.modules || {}).find(
|
|
165
|
-
(candidate) =>
|
|
166
|
-
entityTypeNameOf(Definition.modules[candidate]?.definition) ===
|
|
167
|
-
entity.moduleName
|
|
168
|
-
);
|
|
169
|
-
const resolved = resolveScope({
|
|
170
|
-
binding: key === undefined ? undefined : bindingScopeSetting(Definition, key),
|
|
171
|
-
integration: ownership.scope,
|
|
172
|
-
});
|
|
173
|
-
return resolved.decidedBy === 'default' ? null : resolved;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
module.exports = { ResolveIntegrationOwnership };
|
package/modules/entity-scope.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Entity scopes and ownership (ADR-056 §3), re-exported from the four files it
|
|
3
|
-
* is split into:
|
|
4
|
-
*
|
|
5
|
-
* scope-resolution constants, scope resolution, ownership on create
|
|
6
|
-
* scope-config reading and validating app-definition settings
|
|
7
|
-
* scope-visibility who may see which stored row
|
|
8
|
-
* scope-policy the object a use case asks
|
|
9
|
-
*/
|
|
10
|
-
module.exports = {
|
|
11
|
-
...require('./scope-resolution'),
|
|
12
|
-
...require('./scope-config'),
|
|
13
|
-
...require('./scope-visibility'),
|
|
14
|
-
...require('./scope-policy'),
|
|
15
|
-
};
|
package/modules/scope-config.js
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
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
|
-
};
|
package/modules/scope-policy.js
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
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 };
|