permission: batch apply conditions payload

Signed-off-by: Vincenzo Scamporlino <vincenzos@spotify.com>
This commit is contained in:
Vincenzo Scamporlino
2025-04-11 10:34:21 +02:00
parent ec42d827c2
commit 4d4ec58d6b
5 changed files with 202 additions and 61 deletions
@@ -567,6 +567,101 @@ describe('createPermissionIntegrationRouter', () => {
});
});
describe('batched requests with resourceRefs', () => {
let response: Response;
beforeEach(async () => {
const app = express().use(
createPermissionIntegrationRouter(mockedOptionResources).use(
middleware.error(),
),
);
mockTestRule1Apply.mockReturnValueOnce(true);
mockTestRule1Apply.mockReturnValueOnce(false);
mockTestRule1Apply.mockReturnValueOnce(false);
response = await request(app)
.post('/.well-known/backstage/permissions/apply-conditions')
.send({
items: [
{
id: '123',
resourceRefs: [
'default:test/resource-1',
'default:test/resource-2',
],
resourceType: 'test-resource',
conditions: {
rule: 'test-rule-1',
resourceType: 'test-resource',
params: {
foo: 'a',
bar: 1,
},
},
},
{
id: '234',
resourceRef: 'default:test/resource-3',
resourceType: 'test-resource',
conditions: {
rule: 'test-rule-1',
resourceType: 'test-resource',
params: {
foo: 'a',
bar: 1,
},
},
},
{
id: '345',
resourceRef: 'default:test/resource-2',
resourceType: 'test-resource-2',
conditions: {
not: {
rule: 'test-rule-1',
resourceType: 'test-resource-2',
params: {
foo: 'a',
bar: 1,
},
},
},
},
],
});
});
it('processes batched requests', () => {
expect(response.status).toEqual(200);
expect(response.body).toEqual({
items: [
{
id: '123',
result: [AuthorizeResult.ALLOW, AuthorizeResult.DENY],
},
{
id: '234',
result: AuthorizeResult.DENY,
},
{ id: '345', result: AuthorizeResult.ALLOW },
],
});
});
it('calls getResources for all required resources at once', () => {
expect(defaultMockedGetResources1).toHaveBeenCalledWith([
'default:test/resource-1',
'default:test/resource-2',
'default:test/resource-3',
]);
expect(defaultMockedGetResources2).toHaveBeenCalledWith([
'default:test/resource-2',
]);
});
});
it('returns 400 when called with incorrect resource type', async () => {
const response = await request(createApp())
.post('/.well-known/backstage/permissions/apply-conditions')
@@ -58,12 +58,22 @@ const permissionCriteriaSchema: z.ZodSchema<
const applyConditionsRequestSchema = z.object({
items: z.array(
z.object({
id: z.string(),
resourceRef: z.string(),
resourceType: z.string(),
conditions: permissionCriteriaSchema,
}),
z.union([
z.object({
id: z.string(),
resourceRef: z.string(),
resourceRefs: z.undefined().optional(),
resourceType: z.string(),
conditions: permissionCriteriaSchema,
}),
z.object({
id: z.string(),
resourceRef: z.undefined().optional(),
resourceRefs: z.array(z.string()),
resourceType: z.string(),
conditions: permissionCriteriaSchema,
}),
]),
),
});
@@ -73,11 +83,20 @@ const applyConditionsRequestSchema = z.object({
*
* @public
*/
export type ApplyConditionsRequestEntry = IdentifiedPermissionMessage<{
resourceRef: string;
resourceType: string;
conditions: PermissionCriteria<PermissionCondition>;
}>;
export type ApplyConditionsRequestEntry = IdentifiedPermissionMessage<
| {
resourceRef: string;
resourceRefs?: undefined;
resourceType: string;
conditions: PermissionCriteria<PermissionCondition>;
}
| {
resourceRef?: undefined;
resourceRefs: string[];
resourceType: string;
conditions: PermissionCriteria<PermissionCondition>;
}
>;
/**
* A batch of {@link ApplyConditionsRequestEntry} objects.
@@ -94,8 +113,12 @@ export type ApplyConditionsRequest = {
*
* @public
*/
export type ApplyConditionsResponseEntry =
IdentifiedPermissionMessage<DefinitivePolicyDecision>;
export type ApplyConditionsResponseEntry = IdentifiedPermissionMessage<
| DefinitivePolicyDecision
| {
result: Array<AuthorizeResult.ALLOW | AuthorizeResult.DENY>;
}
>;
/**
* A batch of {@link ApplyConditionsResponseEntry} objects.
@@ -158,6 +181,16 @@ const applyConditions = <TResourceType extends string, TResource>(
return rule.apply(resource, criteria.params ?? {});
};
function authorizeResult<TResourceType extends string, TResource>(
criteria: PermissionCriteria<PermissionCondition<TResourceType>>,
resource: TResource | undefined,
getRule: (name: string) => PermissionRule<TResource, unknown, TResourceType>,
) {
return applyConditions(criteria, resource, getRule)
? AuthorizeResult.ALLOW
: AuthorizeResult.DENY;
}
/**
* Takes some permission conditions and returns a definitive authorization result
* on the resource to which they apply.
@@ -478,7 +511,7 @@ export function createPermissionIntegrationRouter<
router.post(
'/.well-known/backstage/permissions/apply-conditions',
async (req, res: Response<ApplyConditionsResponse | string>) => {
async (req, res: Response<ApplyConditionsResponse>) => {
const parseResult = applyConditionsRequestSchema.safeParse(req.body);
if (!parseResult.success) {
throw new InputError(parseResult.error.toString());
@@ -503,20 +536,31 @@ export function createPermissionIntegrationRouter<
requestedType,
requests
.filter(r => r.resourceType === requestedType)
.map(i => i.resourceRef),
.map(i =>
typeof i.resourceRef === 'string'
? [i.resourceRef]
: i.resourceRefs,
)
.flat(),
);
}
res.json({
items: requests.map(request => ({
id: request.id,
result: applyConditions(
request.conditions,
resourcesByType[request.resourceType][request.resourceRef],
store.getRuleMapper(request.resourceType),
)
? AuthorizeResult.ALLOW
: AuthorizeResult.DENY,
result: request.resourceRefs
? request.resourceRefs.map(resourceRef =>
authorizeResult(
request.conditions,
resourcesByType[request.resourceType][resourceRef],
store.getRuleMapper(request.resourceType),
),
)
: authorizeResult(
request.conditions,
resourcesByType[request.resourceType][request.resourceRef],
store.getRuleMapper(request.resourceType),
),
})),
});
},