fix(security): gate board-key collections

Apply live owner grant checks across every effective company for collection routes and encode next-request revocation behavior.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-06 14:15:41 +00:00 committed by cryppadotta
parent 649c97cf45
commit 4daa9af530
2 changed files with 55 additions and 8 deletions

View File

@ -65,7 +65,7 @@ function createLiveAuthorityDb() {
: table === instanceUserRoles
? (state.instanceAdmin ? [{ id: randomUUID() }] : [])
: table === principalPermissionGrants
? [...state.permissionGrants].map((permissionKey) => ({ permissionKey }))
? [...state.permissionGrants].map((permissionKey) => ({ companyId, permissionKey }))
: [];
return {
where: () => Promise.resolve(rows),
@ -261,4 +261,31 @@ describe("board-key effective authority", () => {
metadata,
), 403);
});
it("applies owner permission-grant revocation to company collections on the next request", async () => {
const { db, state, companyId } = createLiveAuthorityDb();
const authentication = await boardAuthService(db).authenticateBoardApiKey(TOKEN);
const req = requestFor(authentication);
const metadata = {
...lookupBoardKeyRoute("GET", "/api/companies"),
action: "agents:write" as const,
};
await expect(authorizeBoardKey(
db,
req,
metadata.action,
async () => ({ companyId: null, resourceType: "company_collection", resourceId: null }),
metadata,
)).resolves.toBeUndefined();
state.permissionGrants.delete("agents:configure");
await expectDenied(authorizeBoardKey(
db,
req,
metadata.action,
async () => ({ companyId: null, resourceType: "company_collection", resourceId: null }),
metadata,
), 403);
});
});

View File

@ -585,22 +585,33 @@ const OWNER_GRANT_REQUIREMENTS: Partial<Record<BoardPermissionKey, readonly Perm
async function ownerHasRequiredGrant(
db: Db,
ownerUserId: string,
companyId: string,
companyIds: readonly string[],
action: BoardPermissionKey,
) {
const permissionKeys = OWNER_GRANT_REQUIREMENTS[action];
if (!permissionKeys) return true;
const rows = await db
.select({ permissionKey: principalPermissionGrants.permissionKey })
.select({
companyId: principalPermissionGrants.companyId,
permissionKey: principalPermissionGrants.permissionKey,
})
.from(principalPermissionGrants)
.where(and(
eq(principalPermissionGrants.companyId, companyId),
inArray(principalPermissionGrants.companyId, [...companyIds]),
eq(principalPermissionGrants.principalType, "user"),
eq(principalPermissionGrants.principalId, ownerUserId),
inArray(principalPermissionGrants.permissionKey, [...permissionKeys]),
));
const liveKeys = new Set(rows.map((row) => row.permissionKey));
return permissionKeys.every((permissionKey) => liveKeys.has(permissionKey));
const liveKeysByCompany = new Map<string, Set<string>>();
for (const row of rows) {
const liveKeys = liveKeysByCompany.get(row.companyId) ?? new Set<string>();
liveKeys.add(row.permissionKey);
liveKeysByCompany.set(row.companyId, liveKeys);
}
return companyIds.every((companyId) => {
const liveKeys = liveKeysByCompany.get(companyId);
return permissionKeys.every((permissionKey) => liveKeys?.has(permissionKey));
});
}
async function auditDecision(
@ -689,9 +700,18 @@ export async function authorizeBoardKey(
await denyBoardKey(db, req, metadata, resource, "owner_instance_admin_missing", "forbidden");
}
} else if (metadata.classification === "company_collection") {
if ((req.actor.companyIds ?? []).length === 0) {
const companyIds = req.actor.companyIds ?? [];
if (companyIds.length === 0) {
await denyBoardKey(db, req, metadata, resource, "owner_company_membership_missing", "forbidden");
}
if (!await ownerHasRequiredGrant(
db,
req.actor.boardKeyOwnerId!,
companyIds,
action as BoardPermissionKey,
)) {
await denyBoardKey(db, req, metadata, resource, "owner_permission_grant_missing", "forbidden");
}
} else if (metadata.resolver === "downstream_company") {
// A family may be inventoried before it has a safe generic resolver. It is
// available to migrated legacy keys through existing route-level checks,
@ -710,7 +730,7 @@ export async function authorizeBoardKey(
if (!await ownerHasRequiredGrant(
db,
req.actor.boardKeyOwnerId!,
resource.companyId,
[resource.companyId],
action as BoardPermissionKey,
)) {
await denyBoardKey(db, req, metadata, resource, "owner_permission_grant_missing", "forbidden");