Repository navigation
Description
POST /v1/instrument-repos/:id/sync and POST /v1/instrument-repos ignore the group scope of the grant that admits the caller. A per-user permission on InstrumentRepo can be confined to one group: GROUP_SCOPED_CONDITIONS maps it to { groupIds: { hasSome: [groupId] } }, and isGrantablePermission allows it. The @RouteAccess guard only checks the subject type, so a scoped grant passes it. After that, deleteById and findAll apply accessibleQuery, but sync reads the repository by bare id and the controller never forwards the ability. A user granted update InstrumentRepo for group A can therefore re-sync any repository on the instance. A sync can create new instrument editions, and InstrumentsService.create adds those to every group that had the previous edition. A user granted create InstrumentRepo for group A can post the URL of any already-registered repository. create then overwrites that repository's stored access token with whatever the caller supplied and syncs it. Replacing a working token with an invalid one breaks every later sync of a private repository for all groups that use it.
Where
apps/api/src/instrument-repos/instrument-repos.controller.ts:35-40: no ability is forwarded:
@Post(':id/sync')
@RouteAccess({ action: 'update', subject: 'InstrumentRepo' })
sync(@Param('id') id: string) {
return this.instrumentReposService.sync(id);
}apps/api/src/instrument-repos/instrument-repos.service.ts:117-121:
async sync(id: string) {
const repo = await this.instrumentRepoModel.findFirst({ where: { id } });apps/api/src/instrument-repos/instrument-repos.service.ts:43-51: an existing URL overwrites the token and syncs, whoever the caller is:
const existing = await this.instrumentRepoModel.findFirst({ where: { url: normalizedUrl } });
if (existing) {
if (accessToken) {
await this.instrumentRepoModel.update({
data: { accessToken: this.encrypt(accessToken) },
where: { id: existing.id }
});
}
return this.sync(existing.id);
}Reproduce
- As an admin, register a private repository R and assign it only to group B. Create a STANDARD user in group A and grant them
update InstrumentRepoandcreate InstrumentRepo, both scoped to group A. - As that user,
POST /v1/instrument-repos/<R id>/sync. - As that user,
POST /v1/instrument-reposwith{ "url": "<R url>", "accessToken": "invalid" }.
Actual: step 2 syncs R. Step 3 replaces R's stored token, and every later sync of R fails with 502 for everyone.
Expected: step 2 returns 404 because R is outside the grant's scope, which is how deleteById already treats a repository outside a scoped delete grant. Step 3 is refused with 409 (the URL is @unique), and R's token is left untouched.
Tests
apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts:it('should scope sync by the caller's update rules, so a group-scoped grant cannot reach another group's repository'), assertingaccessibleQuery(ability, 'update', 'InstrumentRepo')in thefindFirstwhere. Add a matching case forcreatere-syncing an existing URL.testing/src/specs/authorization.spec.ts: a user holding a group-scopedupdate InstrumentRepogrant gets 404 when syncing a repository outside that group.
Suggested fix
Forward @CurrentUser('ability') to sync and create, and put accessibleQuery(ability, 'update', 'InstrumentRepo') in both findFirst calls. In create, refuse an existing URL the caller cannot update with a ConflictException before touching its token.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Nothing underapps/api/src/instrument-repos/,ability.factory.tsorability.utils.tshas changed since this was filed, and the cited lines are the same.createneeds an object-level check, not a scoped lookupThe suggested fix puts
accessibleQuery(ability, 'update', 'InstrumentRepo')in bothfindFirstcalls. That is right forsync, because the guard guarantees the caller holds anupdaterule. Increateit fails in two ways:create InstrumentRepoand noupdaterule,accessibleQuerythrows CASL'sForbiddenError, which comes back as a 500 (.agents/docs/architecture/auth-and-permissions.md, Layer 2).updatescope excludes the repository, the scoped lookup returnsnull.createthen downloads and imports the repository, which creates instruments, and only fails afterwards on the@uniqueurl.Keep the lookup by
urlunscoped. Before the token write, throw theConflictExceptionunlessability.can('update', existing). Prisma results already carry__modelName, so this check works on the row as returned. Then callthis.sync(existing.id, { ability }).A new URL ignores the scope too
createsets nogroupIdson a new repository, and nothing checks the condition on thecreaterule. So acreate InstrumentRepogrant confined to group A can register any repository. This hole does more damage than the other two. The import runs every instrument throughInstrumentsService.create(instruments.service.ts:121-135). The edition push described above for sync therefore happens here too, with content the grantee chooses. Anyone who controls a GitHub repository can publish a new edition of any instrument in use into every group.POST /v1/groupshad the same shape and was madeADMIN_ONLY(#1468). A narrower fix keeps instance-wide grants working. When the URL is not yet registered, refuse with 403 before the download unlessability.can('create', forcedAppSubject('InstrumentRepo', { groupIds: [] })). A scoped rule'shasSomeis false on an empty list, and an unconditioned rule passes.assignments.service.ts:86uses the same pattern for an assignment with no group. After this change, a group-scopedcreate InstrumentReporeaches nothing.auth-and-permissions.mdsays such a pair belongs inisGrantablePermission(packages/schemas/src/core/core.ts:73-74), which will need to takegroupIdto express it.Open question: an instance-wide
create InstrumentRepogrant is also an instrument upload by another route, whilePOST /v1/instrumentsrequiresmanage Instrument. Should this route require both, using the array form of@RouteAccess?The scope can be widened from the group side
A group-scoped grant matches on
InstrumentRepo.groupIds. That field has one writer: the nestedinstrumentRepos: { set }inGroupsService.updateById(groups.service.ts:134-138). It accepts any ids. EveryGROUP_MANAGERholdsmanage Groupon their own groups (ability.factory.ts:59). A manager of A who holds a scopedupdate InstrumentRepofor A canPATCH /v1/groups/<A>withinstrumentRepoIds: [<R id>], and R is then in scope for both routes. Repository ids are not secret, because instrument info returns them assourceRepo.id(instruments.service.ts:576-578). Only/admin/groupssendsinstrumentRepoIds(apps/web/src/routes/_app/admin/groups/index.tsx:33-40), and/group/managesends onlyaccessibleInstrumentIdsandsettings. Refusing that field unlessability.can('manage', 'all')would therefore close this with no client change. It deserves its own issue, landed together with this one.Tests
instrument-repos.service.test.ts: coverupdate;createfor a new URL refused beforeimportInstruments;createwith no ability still working, sincesetup.service.ts:157calls it that way.A re-add case that passes an ability needs a mock with
__modelName: 'InstrumentRepo'andgroupIds. The existing{ id: 'existing' }mocks resolve to subject typeObject. Ininstrument-repos.controller.test.ts, thecreateandsynccases should expect{ ability }.The proposed e2e cannot fail. The e2e stack never holds a repository (
admin-instrument-repos.spec.ts:3-6), so syncing any id is a 404 with or without the fix. Pin the sync 404, and the re-add 409 with the stored token unchanged, in a newapps/api/test/suites/04-instrument-repo-scoping.suite.ts. That suite can insert theInstrumentReporow through Prisma, as03-record-file-deletion.suite.tsdoes for its rows. Once fixed, both refusals happen before any download.e2e: in
testing/src/specs/authorization.spec.ts, underserver-side authorization, a user grantedcreate InstrumentReposcoped to their own group gets 403 fromPOST /v1/instrument-repos. feat: add the instrument hub and record provenance #1807 also edits this file, so expect a rebase.#1633 has the same shape on
PATCH /v1/instrument-records/:id(an unscopedfindFirstahead of the write) and can land alongside.Triage note written with Claude Code. Code references point at
main@d86740e.fix(api): refuse re-adding an instrument repo the caller cannot update