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:
parent
649c97cf45
commit
4daa9af530
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue