Repository navigation
feat(core): entity scopes and ownership by scope (ADR-056 §3.1–§3.4, §3.6) - #682
seanspeaks wants to merge 10 commits into
Conversation
Add getEntityTypeRegistry(appDefinition), a pure function that merges the
optional appDefinition.entities map with the modules bound by integrations.
Inference stays on unless entities.$infer is false.
With no entities key nothing new can fail: two different definitions under
one moduleName keep the first and log a warning. With entities present
(even {}) the rules are strict: a key must equal moduleName and getName(),
one name is one definition (same object, or same package@version), and
$infer: false requires every bound module to be registered.
loadAppDefinition builds the registry once, at load, so errors fail fast,
and returns entityTypeRegistry and moduleDefinitions. The v1 router, v2
dependencies, health, the webhook loader and the worker entry read those;
createIntegrationCommands and createQueueWorker take moduleDefinitions and
fall back to the integration's own modules when none is given.
getModulesDefinitionFromIntegrationClasses is removed.
GET /api/v2/entities/types[/:type] lists registry-only types, looks them up
by registry key, and each description gains usedBy. app-definition.schema.json
gains entities; types/modules/index.d.ts adds the registry types.
This commit does not change which fields are encrypted:
loadModuleEncryptionSchemas keeps its existing (broken) require path, with a
comment pointing to claude/internal-mcp-03b-module-credential-encryption.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
Conflicts: the loader keeps both the adopter-JWT config assert and the registry; v2 dependencies keep the registry imports and F4's buildUserAuthentication; types reference both user and modules. The registry router test gains F4's membership-repository mock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
…ntities and integrations (ADR-056 §3.1-§3.4, §3.6) - scope-resolution / scope-config / scope-visibility / scope-policy (re-exported by entity-scope.js): scope resolution (request > binding > integration > entity type > app > default from user.primary), ownership on create, one visibility clause list used as the repository query and as the in-memory access check, legacy-row rules. - Entity and Integration gain a nullable organizationId (both Prisma schemas, one PostgreSQL migration). No scope column: the owner's user type says it. - AuthorizeEntityForUser (v1 and v2) owns by scope and pairs with the active organization. Lists, integration create (v1 and v2) and every entity load use the same rule; v1 integration create now checks entity ownership. - ResolveIntegrationOwnership refuses mixed-scope integrations with a 400 before anything is written; CreateIntegration validates before persisting; an organization-owned legacy integration is adopted on a repeated create. - The entity proxy fails closed for organization-scoped entities until proxy.scopes (F6). - ownership config validated at app-definition load; schemas, OpenAPI, types and docs/guides/entity-scopes.md updated. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
There was a problem hiding this comment.
If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.
Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.
…ps' into claude/internal-mcp-05-scope-ownership
…rize uses the scope owner - _mapEntity (postgres, mongo, documentdb) spread the provider's `data` over `id` and `userId` and only pinned `organizationId` afterwards, so a stored data.id or data.userId replaced the row's identity, which drives scope, access and every later load. `data` is now spread first and every column is pinned after it. The pre-F5 mappers had the same order (data last), so this was an existing weakness that F5's ownership checks made dangerous. The integration repositories map `config` explicitly and do not spread. - v1 GET /api/authorize built the module with user.getId() while POST stores under the resolved scope owner. It now asks AuthorizeEntityForUser for the owner, as POST does. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
…cated registry wrapper #679 removed the export. ADR-059 §2 keeps it as a deprecated wrapper so adopters that import it keep working. It now returns the entity type registry's definitions (optionally including app.entities), logs a deprecation warning once per process, and is exported again from @friggframework/core and integrations/. Typed in types/modules. The "registered": false list flag of ADR-059 §4 is not built; the reference doc says so (the ADR is amended on the ADR branch). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
findEntitiesByOwnership and findIntegrationsByOwnership read only the first cursor batch (findMany returns cursor.firstBatch), so lists past ~101 documents lost rows, the same defect #681 fixed for memberships. Both now use findManyDrained. The bulk credential read behind entity lists is drained too, or entities past the first batch of the $in read came back without their credential. Tested over a fake server holding 2500 documents per collection (more than one getMore); each of the three reads reverted to findMany fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
…ernal-mcp-05-scope-ownership Brings in the deprecated getModulesDefinitionFromIntegrationClasses wrapper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
… routes build the module for the owner POST /api/entity took user.getId() as the owner and set no organizationId, so it fell outside the ownership rules (an org-primary member could create an org-owned row). It also called Module.findOrCreateEntity, which no longer exists, so it failed on every call. It now goes through a new CreateEntityFromCredential use case: the owner comes from AuthorizeEntityForUser.resolveOwnership (as /api/authorize), the credential must belong to that owner (400 INVALID_CREDENTIAL otherwise, the same for a missing one), the module is built with the credential loaded, and the entity is found or created with the resolved organization. TestModuleAuth, GetModule, GetEntityOptionsById and RefreshEntityOptions (v1 test-auth, GET /api/entities/:id and the options routes) built the Module with the caller's id. A member using an organization's entity there would, on a token refresh, upsert a new credential under the member. They now build it for entity.userId, as v2 does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d8626c4. Configure here.
| if (!credential || !sameId(credential.userId, ownership.ownerId)) { | ||
| throw Boom.badRequest('Invalid credential ID', { | ||
| code: 'INVALID_CREDENTIAL', | ||
| }); |
There was a problem hiding this comment.
Credential data can override owner check
Medium Severity
CreateEntityFromCredential authorizes the request with credential.userId from findCredentialById, but those adapters still spread the JSON data blob after the identity columns. A userId (or id) key inside stored token data replaces the real owner, so the new INVALID_CREDENTIAL check can accept another user's credential or reject a valid one.
Reviewed by Cursor Bugbot for commit d8626c4. Configure here.
Frigg 2.0.1 review (Stack A) — COMMENT onlyVerdict (would): changes needed Cross-org gates ( Blocking
Should-fix
Nits
No formal approve / request-changes on this review. |
| // Otherwise fall back to simple equality check | ||
| const isOwned = typeof userIdOrUser === 'object' && userIdOrUser?.ownsUserId | ||
| ? userIdOrUser.ownsUserId(entity.userId) | ||
| ? this.scopePolicy.canUseEntity(userIdOrUser, entity) |
There was a problem hiding this comment.
blocking — widening canUseEntity exposes v1 credential payloads to org members
Switching from ownsUserId to canUseEntity correctly implements ADR-056 use-access — but the return value below still includes credential: module.credential (full decrypted secrets). Integration DTOs similarly embed populated entities[].
Scenario: Individual-primary member of org X calls v1 GET /api/entities/:id (or lists integrations) for an org-owned connection → receives OAuth tokens / API keys. Those rows were not visible under individual primary before this PR. v2 masks credentials; v1 is still on by default.
Please strip secrets from v1 entity/integration responses (mirror v2: credentialId + authIsValid only), or refuse to enable ownership scopes while v1 returns raw credentials.
There was a problem hiding this comment.
Verdict: confirmed, and wider than described. Checked on this branch's head (d8626c44) with a throwaway test (not committed).
Live today, for org members
GET /api/entities(integration-router.js→GetEntitiesForUser.execute,modules/use-cases/get-entities-for-user.js:30-40).findEntitiesByOwnershippopulatescredentialthrough_mapEntity(module-repository-postgres.js:238-241,module-repository-mongo.js:199-202).mapModuleClassToModuleDTO(modules/utils/map-module-dto.js:12) then returnsentity: moduleInstance.entityunfiltered. A member of org 17 who lists entities gets org 17's entity withentity.credential.data.access_token/refresh_token. The test asserted this and it passed.GET /api/integrations(v1) andGET /api/v2/integrations.GetIntegrationsForUser.toDto(integrations/use-cases/get-integrations-for-user.js:69-98) embedsentitiesfromfindEntitiesByIds, which also populates the credential. v1mapIntegrationClassToIntegrationDTOpasses them through (map-integration-dto.js:12), and so does v2toIntegrationDto(management-api/v2/dto.js,entities: integration.entities ?? []). So "v2 masks credentials" holds only for v2 entity responses. v2 integration responses leak the same way. (The sametoIntegrationDtois on feat: Management API v2, /api/meta, v1 deprecation and the locked-down entity proxy (ADR-052, ADR-053) #671.)
Latent: GET /api/entities/:entityId
GetModule.execute(modules/use-cases/get-module.js:69-76) returnscredential: module.credential, which is the full decrypted record. Today this route 500s before it gets there:Module.getEntityModelFromDefinition(get-module.js:49) is not defined anywhere (typeof Module.getEntityModelFromDefinition === 'undefined'on this head). The tests that cover it mock the static. Once the static exists (fix(devtools): route /api/entity and /api/entities to the integration router #667 adds the missing piece), the member getscredential.data.refresh_token. The test asserted that too.
Minimal fix (recommended before ownership scopes merge)
- Add one shared mapper, e.g.
modules/utils/public-entity.js→toPublicEntity(entity). It returns the entity withoutcredential, pluscredentialIdandauthIsValid, the same fields v2'stoEntityDtoexposes. - Use it in
mapModuleClassToModuleDTO(entity: toPublicEntity(...), which already hascredentialId), inGetModule(replacecredential:withcredentialId+authIsValid), inmapIntegrationClassToIntegrationDTO(entities.map(toPublicEntity)), and in v2toIntegrationDto. - Apply it to every caller, not only members. An owner has no need for their own refresh token over HTTP either, and one rule is easier to test. The only in-repo consumer I found reads
result.credential?.idfrom the authorize response (packages/ui/lib/api/api.js:267), which is a different route. - Add a regression test: an org member lists entities and integrations, and asserts that no
access_token/refresh_token/dataappears anywhere in the JSON.
🤖 Generated with Claude Code
There was a problem hiding this comment.
Confirmed, and fixed in 451c957b (pushing now).
What leaked (reproduced by the new test on the previous commit):
- v1
GET /api/integrations: inentities.authorized, the entity record came frommapModuleClassToModuleDTO. - v1
GET /api/integrationsand v2GET /api/v2/integrations: the integration DTOs embedded the entities fromfindEntitiesByIds. - v1
GET /api/entities/:id:GetModulereturnedmodule.credential.
Fix: one shared toPublicEntity in modules/utils/public-entity.js.
- It keeps a fixed set of descriptive fields and reduces the credential to
credentialIdandauthIsValid, for every caller including the owner. - It is used by:
- the module DTO;
GetModule;- the v1 integration DTO;
- v2
toIntegrationDto; - v1
GET /api/integrations/:id; - the
POST /api/entityresponse.
#667: this branch doesn't contain it, and its CreateEntityFromCredential differs from #667's, so I didn't rebase.
- I ported
findModuleDefinitionForEntityunchanged from f74cf3b.GetModule,TestModuleAuth,GetEntityOptionsByIdandRefreshEntityOptionsno longer call the missingModule.getEntityModelFromDefinition. GET /api/entities/:idanswers 200 again.
Test: management-api/credential-exposure.test.js uses the real v1 and v2 routers and use cases.
- The repositories attach the decrypted credential, as the real ones do.
- It signs in as a member of the organization that owns the connection.
- It calls:
- v1
/api/integrations,/api/integrations/:idand/api/entities/:id; - v2
/api/v2/entities,/entities/:id,/integrationsand/integrations/:id.
- v1
- It asserts no
access_token,refresh_token,api_key,id_tokenorclient_secretvalue or key, and no credential object, appears in any body. - It also asserts the connection is present, so a 404 or an empty list can't pass.
Mutation checks:
- Undoing the module DTO, the v1 integration DTO or
GetModuleeach fails it. - Undoing the v2
toIntegrationDtois caught by a new unit test indto.test.js. In the router test that layer is covered twice, so the router test alone doesn't catch it.
Wider run: the modules, integrations, management-api, handlers, user and credential suites fail the same 61 tests by name as before, with 12 more passing.
Generated by Claude Code
…on responses The repositories attach the decrypted credential to every entity they return, and three responses sent those records as they were, so a member of an organization received the organization connection's access_token, refresh_token or api_key: - v1 GET /api/integrations (entities.authorized): mapModuleClassToModuleDTO returned the entity record; - v1 GET /api/integrations and v2 GET /api/v2/integrations: the integration DTOs embedded the entities from findEntitiesByIds; - v1 GET /api/entities/:id: GetModule returned module.credential. One shared toPublicEntity (modules/utils/public-entity.js) now decides what an entity looks like in any response: descriptive fields plus credentialId and authIsValid, for every caller including the owner. It is used by the module DTO, GetModule, the v1 integration DTO, the v2 toIntegrationDto, v1 GET /api/integrations/:id and the POST /api/entity response. GetModule, TestModuleAuth, GetEntityOptionsById and RefreshEntityOptions also looked up the module definition with Module.getEntityModelFromDefinition, which does not exist, so every /api/entities/:entityId route answered 500. They now use findModuleDefinitionForEntity, ported unchanged from #667 (f74cf3b). management-api/credential-exposure.test.js drives the real v1 and v2 routers as an organization member, with repositories that attach the decrypted credential, and asserts no token, key or credential object appears in any entity or integration response. On the previous commit it fails on the three leaks above. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
There was a problem hiding this comment.
If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.
Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 38932763 | Triggered | Generic High Entropy Secret | 451c957 | packages/core/management-api/credential-exposure.test.js | View secret |
| 38932765 | Triggered | Generic High Entropy Secret | 451c957 | packages/core/management-api/credential-exposure.test.js | View secret |
| 38932766 | Triggered | Generic High Entropy Secret | 451c957 | packages/core/management-api/credential-exposure.test.js | View secret |
| 38932764 | Triggered | Generic High Entropy Secret | 451c957 | packages/core/management-api/credential-exposure.test.js | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secrets safely. Learn here the best practices.
- Revoke and rotate these secrets.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
|





Summary
This implements ADR-056 §3.1–§3.4 and the entity, integration and list parts of §3.6. An entity or integration is now owned by a person, by an organization, or globally, and lists show only what belongs to the active organization.
Stack: this is the first of three PRs: #681 (memberships) → this → #683 (org roles) → #684 (credential scoping). It is stacked on #681 and also merges #679 (entity registry), because the scope chain reads the registry's per-type
scope. Until #679 merges, the diff against #681 includes #679's commits.Scope resolution (
modules/scope-*.js, re-exported fromentity-scope.js)Pure resolution in ADR order:
{ default, allow });settings.scope);appDefinition.ownership;user.primary.ownership.individualAcross('organization'or'all') is supported.Validated at app-definition load (
OwnershipConfigError). This includes §3.3: an organization-owned integration cannot bind an individual entity. Schemas, OpenAPI and types are updated.Data
organizationId(nullable) onEntityandIntegration, in both Prisma schemas, with a Postgres migration (20261016000000_add_entity_integration_organization) and an index on[userId, organizationId].migrate diffis empty.scopecolumn, because the owner already encodes the scope.organizationIdis deliberately not a foreign key.CREATE INDEXtakes a SHARE lock while it builds, and Prisma migrations cannot useCONCURRENTLY. Adopters with very large Entity or Integration tables can pre-create the index by hand under the same name.Ownership on create
organizationId.organizationIdcomes only from the active organization, never from provider data. A tenant request forglobalreturns 403.organizationIdand derives the owner per the ADR table.POST /api/integrationsused to accept any entity id. It now uses the same ownership resolver as v2, which returns 404 before anything is written.Lists and access
/api/v2/entities,/credentialsand/integrationsreturn, for the active organization:owner = caller AND (organizationId = active OR (all AND organizationId IS NULL)).scope,owner,organizationIdandaccess. Lists take?scope=and?owner=me|orgfilters.Legacy rows (existing data,
organizationIdnull)organizationIdpointer.POST /api/integrationsfor a legacy org-owned integration still reuses it, with no duplicate and no secondON_CREATE. This is tested with org-only headers and with a pointer mismatch.managementApi.proxy.scopes, the entity proxy refuses organization-scope entities (ADR §3.5: narrowest by default). Org-primary apps that proxy through org entities must opt in once F6 lands.Left for later PRs
proxy.scopesand the real-Postgres isolation suite: F6.ORPHANED, member removal, andGET /api/v2/me/organizations.Test plan
packages/schemas: 194 pass.Docs:
docs/guides/entity-scopes.mdis new;organization-memberships.mdand the core README are updated.🤖 Generated with Claude Code
https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
Note
High Risk
Changes core tenancy, authorization, integration binding, and list visibility across v1/v2 APIs, with a DB migration and legacy compatibility rules—mistakes could leak or hide the wrong org’s data.
Overview
Implements ADR-056 entity scopes: entities and integrations are owned by individual, organization, or (tenant-refused) global scope, with nullable
organizationIdon both models, Postgres migration, and Mongonull OR unsetmatching for legacy rows.Scope resolution lives in
scope-*.js/createScopePolicy: validated atloadAppDefinition(OwnershipConfigError), applied on authorize/create, and used for list + access via one clause set (findIntegrationsByOwnership/ entity equivalents + in-memorydescribeRecord). v2 (and docs) exposescope,owner,organizationId,accessand?scope/?ownerfilters. Credentials stay unscoped in this PR; entity proxy fails closed for organization-scoped entities until F6proxy.scopes.Integration create resolves owner + org through
ResolveIntegrationOwnership(mixed scopes, scope mismatch, org+individual entity → 400; invisible entities → 404).CreateIntegrationstoresorganizationId, dedupes within org (withadoptUnscopedfor legacy rows), and loads modules before persisting to avoid orphanIN_CREATIONrows. v1POST /api/integrationsand authorize/entity routes now follow the same rules (security fix: no arbitrary entity binding).Also wires ADR-059 entity registry:
loadAppDefinitionreturnsentityTypeRegistry/moduleDefinitions; health, workers, webhooks, andcreateFriggCommandspass the full offered module set (registry-only types authorizeable).getModulesDefinitionFromIntegrationClassesis deprecated;GetIntegrationsByUserIdsplits app-level “all for user id” from request-scopedGetIntegrationsForUser. Integration GET responses strip credentials viatoPublicEntities.New guide
docs/guides/entity-scopes.md; membership docs mark F5 scoping done.Reviewed by Cursor Bugbot for commit 451c957. Bugbot is set up for automated code reviews on this repo. Configure here.
📦 Published PR as canary version:
2.0.0--canary.682.451c957.0✨ Test out this PR locally via:
npm install @friggframework/admin-scripts@2.0.0--canary.682.451c957.0 npm install @friggframework/core@2.0.0--canary.682.451c957.0 npm install @friggframework/devtools@2.0.0--canary.682.451c957.0 npm install @friggframework/eslint-config@2.0.0--canary.682.451c957.0 npm install @friggframework/prettier-config@2.0.0--canary.682.451c957.0 npm install @friggframework/schemas@2.0.0--canary.682.451c957.0 npm install @friggframework/serverless-plugin@2.0.0--canary.682.451c957.0 npm install @friggframework/test@2.0.0--canary.682.451c957.0 npm install @friggframework/ui@2.0.0--canary.682.451c957.0 # or yarn add @friggframework/admin-scripts@2.0.0--canary.682.451c957.0 yarn add @friggframework/core@2.0.0--canary.682.451c957.0 yarn add @friggframework/devtools@2.0.0--canary.682.451c957.0 yarn add @friggframework/eslint-config@2.0.0--canary.682.451c957.0 yarn add @friggframework/prettier-config@2.0.0--canary.682.451c957.0 yarn add @friggframework/schemas@2.0.0--canary.682.451c957.0 yarn add @friggframework/serverless-plugin@2.0.0--canary.682.451c957.0 yarn add @friggframework/test@2.0.0--canary.682.451c957.0 yarn add @friggframework/ui@2.0.0--canary.682.451c957.0