@friggframework/core 2.0.0--canary.707.df80bfd.0 → 2.0.0--canary.680.d670696.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -0
- package/CLAUDE.md +0 -28
- package/README.md +0 -105
- package/application/commands/integration-commands.js +2 -2
- package/application/commands/scheduler-commands.js +7 -11
- package/application/commands/user-commands.js +5 -17
- package/core/Worker.js +7 -31
- package/core/create-handler.js +0 -9
- package/credential/use-cases/credential-operations-for-user.js +25 -110
- package/credential/use-cases/reauthorize-credential-for-user.js +8 -15
- package/database/config.js +24 -48
- package/database/documentdb-encryption-service.js +0 -1
- package/database/documentdb-utils.js +1 -6
- package/database/encryption/encryption-config.js +44 -13
- package/database/encryption/encryption-schema-registry.js +31 -88
- package/database/prisma.js +2 -4
- package/generated/prisma-mongodb/edge.js +4 -27
- package/generated/prisma-mongodb/index-browser.js +1 -24
- package/generated/prisma-mongodb/index.d.ts +48 -3228
- package/generated/prisma-mongodb/index.js +4 -27
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +0 -65
- package/generated/prisma-mongodb/wasm.js +4 -27
- package/generated/prisma-postgresql/edge.js +4 -27
- package/generated/prisma-postgresql/index-browser.js +1 -24
- package/generated/prisma-postgresql/index.d.ts +51 -3503
- package/generated/prisma-postgresql/index.js +4 -27
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +0 -61
- package/generated/prisma-postgresql/wasm.js +4 -27
- package/handlers/app-definition-loader.js +12 -110
- package/handlers/routers/auth.js +12 -40
- package/handlers/routers/health.js +2 -4
- package/handlers/routers/integration-defined-routers.js +92 -144
- package/handlers/routers/integration-webhook-routers.js +64 -102
- package/index.js +1 -64
- package/infrastructure/scheduler/index.js +6 -19
- package/infrastructure/scheduler/scheduler-service-factory.js +7 -55
- package/integrations/index.js +0 -5
- 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 -7
- package/integrations/use-cases/integration-operations-for-user.js +24 -55
- package/integrations/utils/map-integration-dto.js +1 -44
- package/management-api/management-api-config.js +2 -71
- package/management-api/meta-router.js +2 -21
- package/management-api/openapi/components.js +3 -50
- package/management-api/openapi/v2-operations.js +5 -17
- package/management-api/route-registry.js +6 -123
- 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/module-factory.js +0 -12
- package/modules/module.js +2 -53
- package/modules/proxy/proxy-errors.js +1 -12
- package/modules/proxy/proxy-path.js +3 -7
- package/modules/proxy/proxy-policy.js +5 -34
- package/modules/repositories/module-repository-documentdb.js +6 -51
- package/modules/repositories/module-repository-interface.js +2 -19
- package/modules/repositories/module-repository-mongo.js +74 -67
- package/modules/repositories/module-repository-postgres.js +74 -66
- package/modules/repositories/module-repository.js +1 -2
- package/modules/requester/proxy-request.js +1 -2
- package/modules/requester/requester.js +3 -8
- 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 +15 -107
- package/modules/use-cases/get-authorization-requirements.js +2 -2
- package/modules/use-cases/get-entities-for-user.js +13 -26
- package/modules/use-cases/get-entity-module-for-user.js +7 -34
- package/modules/use-cases/get-entity-options-by-id.js +4 -10
- package/modules/use-cases/get-entity-options-by-type.js +1 -1
- package/modules/use-cases/get-module-instance-from-type.js +1 -1
- package/modules/use-cases/get-module.js +4 -10
- package/modules/use-cases/multi-step-authorization.js +13 -40
- package/modules/use-cases/process-authorization-callback.js +19 -53
- package/modules/use-cases/refresh-entity-options.js +4 -10
- package/modules/use-cases/test-module-auth.js +4 -10
- package/package.json +5 -6
- package/prisma-mongodb/schema.prisma +0 -65
- package/prisma-postgresql/schema.prisma +0 -61
- package/queues/index.js +0 -25
- package/types/index.d.ts +0 -1
- package/types/modules/index.d.ts +1 -157
- 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/extensions/bootstrap-runner.js +0 -43
- package/extensions/extension-loader.js +0 -230
- package/extensions/index.js +0 -44
- package/extensions/initialize-app.js +0 -123
- package/extensions/prisma-composer.js +0 -270
- package/extensions/route-mounter.js +0 -103
- package/extensions/schema-composer.js +0 -188
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +0 -216
- package/infrastructure/scheduler/scheduled-job-repository.js +0 -118
- package/integrations/repositories/integration-not-found.js +0 -14
- package/integrations/schedule-config.js +0 -80
- package/integrations/use-cases/dispatch-ongoing-syncs.js +0 -100
- package/integrations/use-cases/resolve-integration-ownership.js +0 -177
- package/modules/entity-scope.js +0 -15
- package/modules/proxy/proxy-bases.js +0 -169
- package/modules/repositories/entity-not-found.js +0 -18
- 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 -247
- 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/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +0 -28
- package/providers/index.js +0 -23
- package/providers/resolve-provider.js +0 -93
- package/providers/runtime-detection.js +0 -124
- package/queues/providers/index.js +0 -11
- package/queues/providers/netlify-background-provider.js +0 -150
- package/queues/providers/qstash-queue-provider.js +0 -219
- package/queues/providers/sqs-queue-provider.js +0 -70
- package/queues/queue-client-interface.js +0 -55
- package/queues/queue-message-auth.js +0 -193
- package/queues/queue-provider-factory.js +0 -101
- package/queues/queue-provider.js +0 -65
- package/queues/sqs-queue-client.js +0 -81
- 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/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
- Existing rows are not rewritten. A credential saved before this release stays plaintext until it is next written, and is read as stored.
|
|
7
7
|
- Reads now tolerate plaintext: a value is only treated as ciphertext when it has the exact `keyId:iv:ciphertext:encryptedKey` shape (previously any string with three or more colons, such as `https://user:pw@host:443`), and a value with that shape that fails to decrypt throws `FieldDecryptionError` in a deployed runtime (any stage, AWS Lambda or Netlify) and is returned as stored with a warning on a local run.
|
|
8
8
|
- An app's own `encryption.schema` is no longer replaced when module fields are added to it.
|
|
9
|
+
- A schema the app declares (`encryption.schema`, `encryption.disable`, module `credentialFields`) that cannot be registered no longer leaves those fields in plaintext silently: a deployed function refuses to start, and a local run logs a warning (it was a debug line).
|
|
10
|
+
- A local run now needs both `AES_KEY_ID` and `AES_KEY` to encrypt with AES, as a deployed one already did; `AES_KEY_ID` alone keeps the run plaintext and warns that `AES_KEY` is missing.
|
|
11
|
+
- The DocumentDB decrypt error no longer logs the first characters of the ciphertext.
|
|
9
12
|
|
|
10
13
|
---
|
|
11
14
|
|
package/CLAUDE.md
CHANGED
|
@@ -332,14 +332,12 @@ class MyIntegration extends IntegrationBase {
|
|
|
332
332
|
- Password-based (bcrypt hashed)
|
|
333
333
|
- Token-based (Bearer tokens)
|
|
334
334
|
- App-based (external app user IDs)
|
|
335
|
-
- Adopter JWT (`authModes.adopterJwt`, `jwtConfig`; `jose`, JWKS or HMAC; see `README.md`)
|
|
336
335
|
|
|
337
336
|
**Use Cases**:
|
|
338
337
|
- `login-user.js` - User authentication
|
|
339
338
|
- `create-individual-user.js` - Create personal account
|
|
340
339
|
- `create-organization-user.js` - Create business account
|
|
341
340
|
- `get-user-from-bearer-token.js` - Token authentication
|
|
342
|
-
- `get-user-from-adopter-jwt.js` - Adopter JWT verification, find-or-create (ADR-056 §2); `adopter-jwt-config.js` validates `jwtConfig` at load
|
|
343
341
|
|
|
344
342
|
**Repositories**:
|
|
345
343
|
- `user-repository-factory.js` - Creates database-specific repositories
|
|
@@ -502,29 +500,6 @@ The single source of the max receive count is
|
|
|
502
500
|
devtools integration builder uses it for the queue's `RedrivePolicy` and sets
|
|
503
501
|
it as `FRIGG_QUEUE_MAX_RECEIVE_COUNT` on the queue worker function.
|
|
504
502
|
|
|
505
|
-
### Management API v2 notes (`/management-api`)
|
|
506
|
-
|
|
507
|
-
- Handlers in `management-api/v2/handlers/` call use cases only; dependencies are wired in
|
|
508
|
-
`management-api/v2/dependencies.js`. Errors are `{ error: { code, message, details? } }`.
|
|
509
|
-
- `appDefinition.managementApi` is resolved by `resolveManagementApiConfig`, which `loadAppDefinition`
|
|
510
|
-
also calls so a bad value stops the app at load. Numeric proxy limits fall back to their defaults;
|
|
511
|
-
`proxy.scopes` (ADR-056 §3.5, default `['individual']`) never does: an unknown scope, `'global'`, a
|
|
512
|
-
non-list, an empty list or a repeated scope throws `ManagementApiConfigError`
|
|
513
|
-
(`MANAGEMENT_API_CONFIG_INVALID`).
|
|
514
|
-
- **Entity proxy** (`ExecuteEntityProxyRequest`): visibility in the active organization comes from
|
|
515
|
-
`GetEntityModuleForUser` (404), then `assertProxyable` reads the same `{ scope, access }` description
|
|
516
|
-
the v2 lists return: `grantsUse(access)` (`use` or `manage`, from `modules/scope-visibility.js`) or 403
|
|
517
|
-
`PROXY_NOT_ALLOWED` `reason: 'access'`, and the scope must be in `proxy.scopes` or 403 `reason: 'scope'`.
|
|
518
|
-
Do not add a second ownership rule for the proxy; change the description instead.
|
|
519
|
-
- **Not visible is 404, and so is missing.** Module repositories throw `entityNotFoundError(id)`
|
|
520
|
-
(`modules/repositories/entity-not-found.js`) for a missing row and use cases match it with
|
|
521
|
-
`isEntityNotFound`. A 500 for a missing id next to a 404 for another tenant's id tells a caller which
|
|
522
|
-
ids exist. A test double that resolves `findEntityById` to `null` hides this: no adapter does that.
|
|
523
|
-
- **Tenant isolation on real PostgreSQL**: `management-api/v2/tenant-isolation.postgres.test.js`, run with
|
|
524
|
-
`FRIGG_TEST_POSTGRES_URL=<a database it may wipe>`. It applies the migrations, truncates every table
|
|
525
|
-
before each test, and drives real adopter-JWT auth through the real router and Prisma repositories.
|
|
526
|
-
Without the variable it is skipped (and says so), not passed. See `docs/guides/entity-scopes.md`.
|
|
527
|
-
|
|
528
503
|
### 8. Error Handling (`/errors`)
|
|
529
504
|
|
|
530
505
|
**Purpose**: Standardized error types with proper HTTP semantics.
|
|
@@ -775,9 +750,6 @@ Use test doubles from `@friggframework/test` package for consistent mocking.
|
|
|
775
750
|
- `SECRET_ARN` - AWS Secrets Manager ARN for auto-injection
|
|
776
751
|
- `FRIGG_LOG_LEVEL` - Minimum log level (`TRACE` … `FATAL`, default `INFO`; `DEBUG` on local runs)
|
|
777
752
|
- `FRIGG_QUEUE_MAX_RECEIVE_COUNT` - Set by devtools on integration queue workers whose queue the stack owns; feeds `delivery.maxReceiveCount`
|
|
778
|
-
- `FRIGG_QUEUE_SIGNING_SECRET` - Required for HTTP-delivered queues (Netlify Background Functions). `NetlifyBackgroundProvider` signs each message (HMAC-SHA256 over `timestamp.rawBody`, `x-frigg-queue-signature` / `x-frigg-queue-timestamp`) and `verifyEvent()` refuses anything unsigned, tampered, signed with another secret or older than 5 minutes. Unset → `send()` throws and every event is refused (`queues/queue-message-auth.js`)
|
|
779
|
-
- `FRIGG_QUEUE_ALLOW_UNSIGNED` - `true` accepts unsigned queue messages when no secret is set, outside production only (`STAGE` dev/test/local, `NODE_ENV` development/test, or `NETLIFY_DEV`); ignored in production and in any deployed function, whatever `STAGE` says (`providers/runtime-detection.js` `isDeployedRuntime`: AWS Lambda variables, or the active provider's `detect()`, e.g. provider-netlify via `FRIGG_PROVIDER=netlify`)
|
|
780
|
-
- `QSTASH_CURRENT_SIGNING_KEY` / `QSTASH_NEXT_SIGNING_KEY` - Required with the QStash queue: `QStashQueueProvider.verifyEvent()` checks the `Upstash-Signature` JWT with them
|
|
781
753
|
|
|
782
754
|
## Version Information
|
|
783
755
|
|
package/README.md
CHANGED
|
@@ -305,7 +305,6 @@ Comprehensive user authentication and authorization system supporting both indiv
|
|
|
305
305
|
- **Password-based**: Traditional username/password authentication
|
|
306
306
|
- **Token-based**: Bearer token authentication with session management
|
|
307
307
|
- **App-based**: External app user ID authentication (passwordless)
|
|
308
|
-
- **Adopter token (JWT)**: A token issued by your own identity system, verified by Frigg and safe to send from a browser (see below)
|
|
309
308
|
|
|
310
309
|
**Usage:**
|
|
311
310
|
```javascript
|
|
@@ -353,110 +352,6 @@ console.log('Individual user:', user.getIndividualUser());
|
|
|
353
352
|
console.log('Organization user:', user.getOrganizationUser());
|
|
354
353
|
```
|
|
355
354
|
|
|
356
|
-
#### Adopter token (JWT) authentication
|
|
357
|
-
|
|
358
|
-
For a multi-tenant product (ADR-056 §2), let the browser send the token your identity system
|
|
359
|
-
already issues. Frigg verifies the signature and claims, then finds or creates the individual
|
|
360
|
-
user and the organization user the token names. Unlike `sharedSecret` (`x-frigg-*` headers), a
|
|
361
|
-
token cannot name a user other than the one it was issued for, so it is safe in a browser.
|
|
362
|
-
|
|
363
|
-
```javascript
|
|
364
|
-
// backend/index.js
|
|
365
|
-
const appDefinition = {
|
|
366
|
-
user: {
|
|
367
|
-
primary: 'organization',
|
|
368
|
-
individualUserRequired: true,
|
|
369
|
-
organizationUserRequired: true,
|
|
370
|
-
authModes: { adopterJwt: true, friggToken: false, sharedSecret: false },
|
|
371
|
-
jwtConfig: {
|
|
372
|
-
jwksUri: 'https://login.example.com/.well-known/jwks.json',
|
|
373
|
-
issuer: 'https://login.example.com/',
|
|
374
|
-
audience: 'my-frigg-api', // required with jwksUri
|
|
375
|
-
},
|
|
376
|
-
},
|
|
377
|
-
};
|
|
378
|
-
```
|
|
379
|
-
|
|
380
|
-
The client sends `Authorization: Bearer <token>`. `x-frigg-appuserid` / `x-frigg-apporgid`, if
|
|
381
|
-
also sent, must match the token or the request is refused with 403. That match rule lives in
|
|
382
|
-
`user/use-cases/authenticate-user.js` (`validateUserMatch`), not in the token verifier. A bearer
|
|
383
|
-
value that is not a three-part JWT is not offered to the verifier at all; it falls through to
|
|
384
|
-
`friggToken` (or is a 401 when that mode is off).
|
|
385
|
-
|
|
386
|
-
| `jwtConfig` field | Default | Meaning |
|
|
387
|
-
|---|---|---|
|
|
388
|
-
| `jwksUri` | | Remote JWKS (rotating keys, one per verifier, cached by jose). Must be `https`; `http` only for `localhost`, `127.0.0.0/8` or `::1`. Set exactly one of this and `secret`. |
|
|
389
|
-
| `secret` | | Shared HMAC secret. Read it from an environment variable. |
|
|
390
|
-
| `issuer` | not checked | Required `iss`. With `jwksUri`, a startup warning is logged when it is unset. |
|
|
391
|
-
| `audience` | not checked with `secret`; **required with `jwksUri`** | Accepted `aud` (a string or a list). |
|
|
392
|
-
| `algorithms` | `['ES256','RS256']` with `jwksUri`, `['HS256']` with `secret` | Allow-list. HS* is only accepted with `secret`, never with `jwksUri`. The older single `algorithm` is a deprecated alias; setting both is an error. |
|
|
393
|
-
| `clockToleranceSec` | `30` | Skew allowed on `exp` and `nbf`. |
|
|
394
|
-
| `userIdClaim` | `sub` | App user id (required in the token). |
|
|
395
|
-
| `orgIdClaim` | `org_id` | App organization id (optional). |
|
|
396
|
-
| `roleClaim` | `org_role` | Exposed as `user.getOrgRole()` for the request, and stored as the `orgRole` of a person's membership the first time they join that organization (`MEMBER` when the token names none). For permissions the token's role wins; a token with none uses the role stored on the person's membership of the active organization, and is a member when there is none: it cannot create or change organization-level entities or integrations. `user.orgAdminRoles` (default `['ADMIN']`) names the roles that can. Frigg bearer tokens are not gated unless `user.orgAdminGateBearerTokens: true`. |
|
|
397
|
-
|
|
398
|
-
Rules worth knowing:
|
|
399
|
-
|
|
400
|
-
- Tokens must carry `exp`. `alg: none` and any algorithm outside the allow-list are refused.
|
|
401
|
-
- Startup fails (`loadAppDefinition` throws) when `adopterJwt` is on and the config is unusable:
|
|
402
|
-
neither or both of `jwksUri` and `secret`, a `jwksUri` that is not https (or loopback http),
|
|
403
|
-
no `audience` with `jwksUri`, or an algorithm list that does not fit the key type.
|
|
404
|
-
- Failures are `401` with a stable `data.reason`: `token_expired`, `token_not_yet_valid`,
|
|
405
|
-
`token_invalid_signature`, `token_wrong_issuer`, `token_wrong_audience`,
|
|
406
|
-
`token_missing_user_claim`, `token_malformed`. The message never contains the token or its claims.
|
|
407
|
-
Any other verification failure, such as an unreachable or unresolvable JWKS, is `503`
|
|
408
|
-
`jwks_unavailable`, not a 401, so clients do not drop good sessions.
|
|
409
|
-
- **The token's organization is the request's active organization**, and a person can belong to
|
|
410
|
-
several (ADR-056 §3.6). The first time a person presents a token for an organization they are
|
|
411
|
-
not a member of, Frigg adds an `OrganizationMembership` and the request runs as that
|
|
412
|
-
organization (`user.getActiveOrganizationId()`; `user.getId()` under `primary: 'organization'`).
|
|
413
|
-
Nothing is moved: `IndividualUser.organizationId` is a legacy pointer, set only on a person's
|
|
414
|
-
first link and read as an implicit membership. The same applies to `x-frigg-apporgid` with
|
|
415
|
-
`sharedSecret`. With `user.strictMembership: true` those headers cannot add anyone to an organization
|
|
416
|
-
that already existed (400 for a non-member, a brand-new `appUserId` included); only an organization
|
|
417
|
-
the request itself creates is allowed. `strictUserValidation` is a deprecated alias with that same
|
|
418
|
-
meaning. See
|
|
419
|
-
[Organization memberships](../../docs/guides/organization-memberships.md). A token naming a second
|
|
420
|
-
organization is no longer a `403` (`multi_org_unsupported` is gone).
|
|
421
|
-
- Two first logins for the same new person or organization, even in different organizations, both
|
|
422
|
-
succeed: a create that loses to a unique constraint finds the winner's row.
|
|
423
|
-
|
|
424
|
-
#### Entity scopes and ownership (ADR-056 §3)
|
|
425
|
-
|
|
426
|
-
Who owns an entity (a connected account) or an integration follows its **scope**, not `user.primary`:
|
|
427
|
-
|
|
428
|
-
| Scope | Owned by | Created with |
|
|
429
|
-
|---|---|---|
|
|
430
|
-
| `individual` | the person (`individualUser`) | the active organization (`organizationId`), or none under `individualAcross: 'all'` |
|
|
431
|
-
| `organization` | the active organization's user | that organization |
|
|
432
|
-
| `global` | nobody (the app owner) | none; refused by the tenant API (admin routes only) |
|
|
433
|
-
|
|
434
|
-
The scope of a new connection is the first of: the request (`scope` on `POST /api/v2/authorize`, only from
|
|
435
|
-
the deciding level's `allow` list), the integration binding (`Definition.modules[key].scope` or
|
|
436
|
-
`Definition.entities[key].scope`), `Definition.ownership.scope`, the entity type
|
|
437
|
-
(`appDefinition.entities[name].scope`), `appDefinition.ownership.scope`, then `organization` for an
|
|
438
|
-
organization-primary app and `individual` otherwise. A setting is `'organization'` or
|
|
439
|
-
`{ default: 'individual', allow: ['individual', 'organization'] }`.
|
|
440
|
-
|
|
441
|
-
```javascript
|
|
442
|
-
const appDefinition = {
|
|
443
|
-
user: { primary: 'individual', authModes: { adopterJwt: true } },
|
|
444
|
-
ownership: { scope: 'individual', individualAcross: 'organization' }, // the defaults
|
|
445
|
-
entities: {
|
|
446
|
-
google: { definition: google, scope: 'individual' },
|
|
447
|
-
xero: { definition: xero, scope: { default: 'organization', allow: ['organization', 'individual'] } },
|
|
448
|
-
},
|
|
449
|
-
integrations: [Sync], // Sync.Definition.ownership = { owner: 'derive' }
|
|
450
|
-
};
|
|
451
|
-
```
|
|
452
|
-
|
|
453
|
-
`GET /api/v2/entities` and `/integrations` return, for the active organization, its own
|
|
454
|
-
rows and the caller's, each with `scope`, `owner`, `organizationId` and `access`, and take `?scope=` and
|
|
455
|
-
`?owner=me|org`. An individual entity is only visible and usable in the organization it was connected in
|
|
456
|
-
(or in all of them, with `individualAcross: 'all'`). Rows written before this existed keep working: see
|
|
457
|
-
[Entity scopes](../../docs/guides/entity-scopes.md), which also covers the schema (`organizationId` on
|
|
458
|
-
`Entity` and `Integration`, no `scope` column) and what is deferred.
|
|
459
|
-
|
|
460
355
|
### 8. Lambda Utilities (`/lambda`)
|
|
461
356
|
|
|
462
357
|
AWS Lambda-specific utilities and helpers.
|
|
@@ -18,7 +18,7 @@ const {
|
|
|
18
18
|
ListIntegrationsByEntityExternalIdUseCase,
|
|
19
19
|
} = require('../../integrations/use-cases/list-integrations-by-entity-external-id');
|
|
20
20
|
const {
|
|
21
|
-
|
|
21
|
+
GetIntegrationsForUser,
|
|
22
22
|
} = require('../../integrations/use-cases/get-integrations-for-user');
|
|
23
23
|
const {
|
|
24
24
|
CreateIntegration,
|
|
@@ -152,7 +152,7 @@ function createIntegrationCommands({ integrationClass, moduleDefinitions } = {})
|
|
|
152
152
|
moduleRepository,
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
-
const getIntegrationsForUserUseCase = new
|
|
155
|
+
const getIntegrationsForUserUseCase = new GetIntegrationsForUser({
|
|
156
156
|
integrationRepository,
|
|
157
157
|
integrationClasses: [integrationClass],
|
|
158
158
|
moduleFactory,
|
|
@@ -121,19 +121,17 @@ function createSchedulerCommands({ integrationName, schedulerService }) {
|
|
|
121
121
|
throw error;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
// the worker function) resolves it; otherwise the target is
|
|
126
|
-
// an SQS queue and its URL is required.
|
|
127
|
-
const service = getSchedulerService();
|
|
128
|
-
const resolvesOwnTarget =
|
|
129
|
-
typeof service?.toQueueResourceId === 'function';
|
|
130
|
-
|
|
131
|
-
if (!queueUrl && !resolvesOwnTarget) {
|
|
124
|
+
if (!queueUrl) {
|
|
132
125
|
const error = new Error('queueUrl is required');
|
|
133
126
|
error.code = 'INVALID_JOB_DATA';
|
|
134
127
|
throw error;
|
|
135
128
|
}
|
|
136
129
|
|
|
130
|
+
// Derive ARN from URL (business logic - transformation)
|
|
131
|
+
const queueArn = deriveArnFromQueueUrl(queueUrl);
|
|
132
|
+
|
|
133
|
+
// Get scheduler service (via DI or factory)
|
|
134
|
+
const service = getSchedulerService();
|
|
137
135
|
if (!service) {
|
|
138
136
|
console.warn(
|
|
139
137
|
`[${integrationName}] Scheduler not configured, skipping job schedule`
|
|
@@ -159,9 +157,7 @@ function createSchedulerCommands({ integrationName, schedulerService }) {
|
|
|
159
157
|
const result = await service.scheduleOneTime({
|
|
160
158
|
scheduleName: jobId,
|
|
161
159
|
scheduleAt: scheduledAt,
|
|
162
|
-
queueResourceId:
|
|
163
|
-
? service.toQueueResourceId(queueUrl)
|
|
164
|
-
: deriveArnFromQueueUrl(queueUrl),
|
|
160
|
+
queueResourceId: queueArn,
|
|
165
161
|
payload: sqsPayload,
|
|
166
162
|
});
|
|
167
163
|
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
const {
|
|
2
2
|
createUserRepository,
|
|
3
3
|
} = require('../../user/repositories/user-repository-factory');
|
|
4
|
-
const {
|
|
5
|
-
createOrganizationMembershipRepository,
|
|
6
|
-
} = require('../../user/repositories/organization-membership-repository-factory');
|
|
7
|
-
const {
|
|
8
|
-
FindIndividualUsersByOrganization,
|
|
9
|
-
} = require('../../user/use-cases/find-individual-users-by-organization');
|
|
10
4
|
|
|
11
5
|
const ERROR_CODE_MAP = {
|
|
12
6
|
USER_NOT_FOUND: 404,
|
|
@@ -32,12 +26,6 @@ function mapErrorToResponse(error) {
|
|
|
32
26
|
*/
|
|
33
27
|
function createUserCommands() {
|
|
34
28
|
const userRepository = createUserRepository();
|
|
35
|
-
const findIndividualUsersByOrganization =
|
|
36
|
-
new FindIndividualUsersByOrganization({
|
|
37
|
-
userRepository,
|
|
38
|
-
organizationMembershipRepository:
|
|
39
|
-
createOrganizationMembershipRepository(),
|
|
40
|
-
});
|
|
41
29
|
|
|
42
30
|
return {
|
|
43
31
|
/**
|
|
@@ -182,8 +170,7 @@ function createUserCommands() {
|
|
|
182
170
|
},
|
|
183
171
|
|
|
184
172
|
/**
|
|
185
|
-
* Find all individual users
|
|
186
|
-
* a membership and those the legacy organizationId pointer places there.
|
|
173
|
+
* Find all individual users linked to an organization user
|
|
187
174
|
* @param {string} organizationUserId - Organization user ID to search for
|
|
188
175
|
* @returns {Promise<Object[]>} Array of individual user objects (empty if none)
|
|
189
176
|
*/
|
|
@@ -195,9 +182,10 @@ function createUserCommands() {
|
|
|
195
182
|
throw error;
|
|
196
183
|
}
|
|
197
184
|
|
|
198
|
-
const users =
|
|
199
|
-
|
|
200
|
-
|
|
185
|
+
const users =
|
|
186
|
+
await userRepository.findIndividualUsersByOrganizationId(
|
|
187
|
+
organizationUserId
|
|
188
|
+
);
|
|
201
189
|
|
|
202
190
|
return (users || []).map((user) => ({
|
|
203
191
|
id: user._id?.toString() || user.id,
|
package/core/Worker.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const { SQSClient, GetQueueUrlCommand, SendMessageCommand } = require('@aws-sdk/client-sqs');
|
|
1
2
|
const _ = require('lodash');
|
|
2
3
|
const { RequiredPropertyError } = require('../errors');
|
|
3
4
|
const { get } = require('../assertions');
|
|
@@ -5,44 +6,18 @@ const { readQueueDelivery } = require('../queues/queue-delivery');
|
|
|
5
6
|
const { runMessageScope } = require('./invocation-scope');
|
|
6
7
|
const { getLogger } = require('../logs');
|
|
7
8
|
|
|
9
|
+
const sqs = new SQSClient({ region: process.env.AWS_REGION });
|
|
8
10
|
const log = getLogger('frigg.worker');
|
|
9
11
|
|
|
10
|
-
/**
|
|
11
|
-
* Worker - queue message producer/consumer base class.
|
|
12
|
-
*
|
|
13
|
-
* Subclass and override _run(). The queue transport is behind
|
|
14
|
-
* QueueClientInterface and can be injected (friggframework/frigg#545):
|
|
15
|
-
* `new MyWorker({ queueClient })`. Unlike #545 it is not required: without
|
|
16
|
-
* one the worker uses an SqsQueueClient configured exactly as before
|
|
17
|
-
* (AWS_REGION), created on first use rather than at module load, so
|
|
18
|
-
* requiring core constructs no AWS client and a Netlify/QStash worker that
|
|
19
|
-
* only calls _run() never touches SQS.
|
|
20
|
-
*/
|
|
21
12
|
class Worker {
|
|
22
|
-
/**
|
|
23
|
-
* @param {Object} [options]
|
|
24
|
-
* @param {import('../queues/queue-client-interface').QueueClientInterface} [options.queueClient]
|
|
25
|
-
*/
|
|
26
|
-
constructor(options = {}) {
|
|
27
|
-
this._queueClient = options.queueClient || null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
_getQueueClient() {
|
|
31
|
-
if (!this._queueClient) {
|
|
32
|
-
const { SqsQueueClient } = require('../queues/sqs-queue-client');
|
|
33
|
-
this._queueClient = new SqsQueueClient({
|
|
34
|
-
clientConfig: { region: process.env.AWS_REGION },
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
return this._queueClient;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
13
|
async getQueueURL(params) {
|
|
41
14
|
// Passing params in because there will be multiple QueueNames
|
|
42
15
|
// let params = {
|
|
43
16
|
// QueueName: process.env.QueueName
|
|
44
17
|
// };
|
|
45
|
-
|
|
18
|
+
const command = new GetQueueUrlCommand(params);
|
|
19
|
+
const data = await sqs.send(command);
|
|
20
|
+
return data.QueueUrl;
|
|
46
21
|
}
|
|
47
22
|
|
|
48
23
|
async run(params, context = {}) {
|
|
@@ -116,7 +91,8 @@ class Worker {
|
|
|
116
91
|
}
|
|
117
92
|
|
|
118
93
|
async sendAsyncSQSMessage(params) {
|
|
119
|
-
const
|
|
94
|
+
const command = new SendMessageCommand(params);
|
|
95
|
+
const data = await sqs.send(command);
|
|
120
96
|
return data.MessageId;
|
|
121
97
|
}
|
|
122
98
|
|
package/core/create-handler.js
CHANGED
|
@@ -81,15 +81,6 @@ const createHandler = (optionByName = {}) => {
|
|
|
81
81
|
if (shouldUseDatabase) {
|
|
82
82
|
const { connectPrisma } = require('../database/prisma');
|
|
83
83
|
await connectPrisma();
|
|
84
|
-
|
|
85
|
-
// Extension bootstraps (e.g. db-credentials swapping a
|
|
86
|
-
// module's env for its DB loader) run once per
|
|
87
|
-
// process, after the connect and before any request
|
|
88
|
-
// builds a module. AWS and Netlify both reach here.
|
|
89
|
-
const {
|
|
90
|
-
initializeApp,
|
|
91
|
-
} = require('../extensions/initialize-app');
|
|
92
|
-
await initializeApp();
|
|
93
84
|
}
|
|
94
85
|
|
|
95
86
|
// Helps reuse the database connection. Lowers response times.
|
|
@@ -1,28 +1,10 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
-
const {
|
|
3
|
-
createScopePolicy,
|
|
4
|
-
describeRecord,
|
|
5
|
-
visibilityClauses,
|
|
6
|
-
matchesListFilters,
|
|
7
|
-
SCOPES,
|
|
8
|
-
} = require('../../modules/entity-scope');
|
|
9
|
-
const { assertCanManage } = require('../../modules/use-cases/manage-access');
|
|
2
|
+
const { userOwns } = require('../../modules/use-cases/get-entity-module-for-user');
|
|
10
3
|
|
|
11
4
|
/**
|
|
12
|
-
* Management API v2 credential operations
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* - its `type` and `entityIds` come from the entities that use it that the
|
|
16
|
-
* caller may see, so the same person's account connected in two
|
|
17
|
-
* organizations (one credential, two entities) lists in each organization
|
|
18
|
-
* with that organization's entity only;
|
|
19
|
-
* - who it belongs to is its OWNER (`credential.userId`), never the entities:
|
|
20
|
-
* a credential an individual entity and an organization entity both point at
|
|
21
|
-
* (possible only through legacy data) is described by whoever owns the
|
|
22
|
-
* credential, so the answer cannot change with the order the entities load;
|
|
23
|
-
* - a credential whose entities are all out of sight (another organization's)
|
|
24
|
-
* is a 404, and one with no entities at all (its entity was deleted) is
|
|
25
|
-
* visible to its owner only, as it was before scoping.
|
|
5
|
+
* Management API v2 credential operations. A credential has no module name
|
|
6
|
+
* of its own; its `type` and the entities that use it come from the
|
|
7
|
+
* caller's entities. A missing or foreign credential is a 404.
|
|
26
8
|
*/
|
|
27
9
|
|
|
28
10
|
const credentialIdOf = (entity) => {
|
|
@@ -55,82 +37,31 @@ function withEntityUsage(credentials, entities) {
|
|
|
55
37
|
});
|
|
56
38
|
}
|
|
57
39
|
|
|
58
|
-
/**
|
|
59
|
-
* `{scope, owner, organizationId, access}` of a credential for this caller, or
|
|
60
|
-
* null when its owner is neither the caller nor the active organization. Built
|
|
61
|
-
* by `describeRecord` over the row a connection by this owner has: the
|
|
62
|
-
* organization of a visible entity, else the one a new entity would get.
|
|
63
|
-
*/
|
|
64
|
-
function credentialTenancy(credential, visibleEntities, ctx) {
|
|
65
|
-
const entityOrg = visibleEntities.find(
|
|
66
|
-
(entity) => entity.organizationId !== undefined && entity.organizationId !== null
|
|
67
|
-
)?.organizationId;
|
|
68
|
-
const organizationId =
|
|
69
|
-
entityOrg ?? (ctx.individualAcross === 'all' ? null : ctx.activeOrgId);
|
|
70
|
-
return describeRecord({ userId: credential.userId, organizationId }, ctx, 'entity');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** The entities of `entities` the caller may see, each with its `tenancy`. */
|
|
74
|
-
function visibleOf(entities, ctx) {
|
|
75
|
-
const visible = [];
|
|
76
|
-
for (const entity of entities || []) {
|
|
77
|
-
const tenancy = describeRecord(entity, ctx, 'entity');
|
|
78
|
-
if (tenancy) visible.push({ ...entity, tenancy });
|
|
79
|
-
}
|
|
80
|
-
return visible;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* The caller's credentials with their type, the entities of them they may see
|
|
85
|
-
* and `tenancy`. Credentials with no entity at all are listed too, for the
|
|
86
|
-
* person and the active organization that own them.
|
|
87
|
-
*/
|
|
40
|
+
/** The caller's credentials with their type and entity usage. */
|
|
88
41
|
class ListCredentialsForUser {
|
|
89
|
-
constructor({ credentialRepository, moduleRepository
|
|
42
|
+
constructor({ credentialRepository, moduleRepository }) {
|
|
90
43
|
this.credentialRepository = credentialRepository;
|
|
91
44
|
this.moduleRepository = moduleRepository;
|
|
92
|
-
this.scopePolicy = scopePolicy || createScopePolicy();
|
|
93
45
|
}
|
|
94
46
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
this.moduleRepository.findEntitiesByOwnership(visibilityClauses(ctx, 'entity')),
|
|
101
|
-
Promise.all(owners.map((id) => this.credentialRepository.findCredentialsByUserId(id))),
|
|
102
|
-
// Every entity of those owners, in any organization: only to tell a credential
|
|
103
|
-
// with no entity at all from one used elsewhere.
|
|
104
|
-
Promise.all(owners.map((id) => this.moduleRepository.findEntitiesByUserId(id))),
|
|
47
|
+
async execute(user) {
|
|
48
|
+
const userId = user.getId();
|
|
49
|
+
const [credentials, entities] = await Promise.all([
|
|
50
|
+
this.credentialRepository.findCredentialsByUserId(userId),
|
|
51
|
+
this.moduleRepository.findEntitiesByUserId(userId),
|
|
105
52
|
]);
|
|
106
|
-
|
|
107
|
-
const usedAnywhere = new Set(owned.flat().map(credentialIdOf).filter(Boolean));
|
|
108
|
-
const listed = [];
|
|
109
|
-
for (const credential of perOwner.flat().filter(Boolean)) {
|
|
110
|
-
const id = String(credential.id);
|
|
111
|
-
const referencing = visible.filter((entity) => credentialIdOf(entity) === id);
|
|
112
|
-
if (referencing.length === 0 && usedAnywhere.has(id)) continue;
|
|
113
|
-
const tenancy = credentialTenancy(credential, referencing, ctx);
|
|
114
|
-
if (!tenancy || !matchesListFilters(tenancy, filters)) continue;
|
|
115
|
-
listed.push({ ...withEntityUsage([credential], referencing)[0], tenancy });
|
|
116
|
-
}
|
|
117
|
-
return listed;
|
|
53
|
+
return withEntityUsage(credentials || [], entities);
|
|
118
54
|
}
|
|
119
55
|
}
|
|
120
56
|
|
|
121
|
-
/** One credential the caller
|
|
57
|
+
/** One credential the caller owns, with its type and entity usage. */
|
|
122
58
|
class GetCredentialForUserV2 {
|
|
123
|
-
constructor({ credentialRepository, moduleRepository
|
|
59
|
+
constructor({ credentialRepository, moduleRepository }) {
|
|
124
60
|
this.credentialRepository = credentialRepository;
|
|
125
61
|
this.moduleRepository = moduleRepository;
|
|
126
|
-
this.scopePolicy = scopePolicy || createScopePolicy();
|
|
127
62
|
}
|
|
128
63
|
|
|
129
64
|
async execute(credentialId, user) {
|
|
130
|
-
const notFound = () =>
|
|
131
|
-
Boom.notFound(`Credential ${credentialId} not found`, {
|
|
132
|
-
code: 'CREDENTIAL_NOT_FOUND',
|
|
133
|
-
});
|
|
134
65
|
let credential = null;
|
|
135
66
|
try {
|
|
136
67
|
credential = await this.credentialRepository.findCredentialById(
|
|
@@ -141,29 +72,22 @@ class GetCredentialForUserV2 {
|
|
|
141
72
|
throw error;
|
|
142
73
|
}
|
|
143
74
|
}
|
|
144
|
-
if (!credential
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return { ...withEntityUsage([credential], visible)[0], tenancy };
|
|
75
|
+
if (!credential || !userOwns(user, credential.userId)) {
|
|
76
|
+
throw Boom.notFound(`Credential ${credentialId} not found`, {
|
|
77
|
+
code: 'CREDENTIAL_NOT_FOUND',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const entities = await this.moduleRepository.findEntitiesByUserId(
|
|
81
|
+
credential.userId
|
|
82
|
+
);
|
|
83
|
+
return withEntityUsage([credential], entities)[0];
|
|
154
84
|
}
|
|
155
85
|
}
|
|
156
86
|
|
|
157
87
|
/**
|
|
158
|
-
* Deletes a credential the caller
|
|
159
|
-
*
|
|
88
|
+
* Deletes a credential the caller owns. The entities that used it stay, with
|
|
89
|
+
* their credential unset, so they show as needing re-authorization rather
|
|
160
90
|
* than disappearing from integrations.
|
|
161
|
-
*
|
|
162
|
-
* Refuses with 409 `CREDENTIAL_SHARED` while an entity the caller cannot see
|
|
163
|
-
* still uses it: one credential row serves the same provider account in every
|
|
164
|
-
* organization, and deleting it from one would break the other. The message
|
|
165
|
-
* says nothing about the other connections (not even how many): remove the
|
|
166
|
-
* entities that use it, and it goes with the last one.
|
|
167
91
|
*/
|
|
168
92
|
class DeleteCredentialForUser {
|
|
169
93
|
constructor({ credentialRepository, moduleRepository, getCredentialForUser }) {
|
|
@@ -177,15 +101,6 @@ class DeleteCredentialForUser {
|
|
|
177
101
|
credentialId,
|
|
178
102
|
user
|
|
179
103
|
);
|
|
180
|
-
assertCanManage(credential.tenancy.access, `credential ${credential.id}`);
|
|
181
|
-
const usedBy =
|
|
182
|
-
(await this.moduleRepository.findEntities({ credentialId: credential.id })) || [];
|
|
183
|
-
if (usedBy.length > credential.entityIds.length) {
|
|
184
|
-
throw Boom.conflict(
|
|
185
|
-
`Credential ${credential.id} is still in use by other connections and cannot be deleted from here`,
|
|
186
|
-
{ code: 'CREDENTIAL_SHARED' }
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
104
|
for (const entityId of credential.entityIds) {
|
|
190
105
|
await this.moduleRepository.unsetCredential(entityId);
|
|
191
106
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
-
const { assertCanManage } = require('../../modules/use-cases/manage-access');
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* Re-authorization of a credential the caller owns (Management API v2). It
|
|
@@ -15,13 +14,7 @@ async function credentialEntityType(getCredentialForUser, credentialId, user) {
|
|
|
15
14
|
{ code: 'CREDENTIAL_TYPE_UNKNOWN' }
|
|
16
15
|
);
|
|
17
16
|
}
|
|
18
|
-
return {
|
|
19
|
-
entityType: credential.type,
|
|
20
|
-
credentialId: String(credential.id),
|
|
21
|
-
// A re-authorization keeps the scope the connection already has.
|
|
22
|
-
fixedScope: credential.tenancy?.scope,
|
|
23
|
-
access: credential.tenancy?.access,
|
|
24
|
-
};
|
|
17
|
+
return { entityType: credential.type, credentialId: String(credential.id) };
|
|
25
18
|
}
|
|
26
19
|
|
|
27
20
|
/** GET /api/v2/credentials/:credentialId/reauthorize */
|
|
@@ -32,12 +25,14 @@ class GetCredentialReauthorization {
|
|
|
32
25
|
}
|
|
33
26
|
|
|
34
27
|
async execute(credentialId, user, { step = 1, sessionId, state } = {}) {
|
|
35
|
-
const
|
|
36
|
-
|
|
28
|
+
const binding = await credentialEntityType(
|
|
29
|
+
this.getCredentialForUser,
|
|
30
|
+
credentialId,
|
|
31
|
+
user
|
|
32
|
+
);
|
|
37
33
|
return this.getAuthorizationStep.execute({
|
|
38
34
|
user,
|
|
39
|
-
|
|
40
|
-
credentialId: boundCredentialId,
|
|
35
|
+
...binding,
|
|
41
36
|
step,
|
|
42
37
|
sessionId,
|
|
43
38
|
state,
|
|
@@ -53,13 +48,11 @@ class ReauthorizeCredentialForUser {
|
|
|
53
48
|
}
|
|
54
49
|
|
|
55
50
|
async execute(credentialId, user, { data, step = 1, sessionId } = {}) {
|
|
56
|
-
const
|
|
51
|
+
const binding = await credentialEntityType(
|
|
57
52
|
this.getCredentialForUser,
|
|
58
53
|
credentialId,
|
|
59
54
|
user
|
|
60
55
|
);
|
|
61
|
-
// A credential an organization owns: the organization's admins only.
|
|
62
|
-
assertCanManage(access, `credential ${credentialId}`);
|
|
63
56
|
return this.submitAuthorizationStep.execute({
|
|
64
57
|
user,
|
|
65
58
|
...binding,
|