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
@@ -34,9 +34,16 @@ const responseSchema = z.object({
items: z.array(
z.object({
id: z.string(),
result: z
.literal(AuthorizeResult.ALLOW)
.or(z.literal(AuthorizeResult.DENY)),
result: z.union([
z.literal(AuthorizeResult.ALLOW),
z.literal(AuthorizeResult.DENY),
z.array(
z.union([
z.literal(AuthorizeResult.ALLOW),
z.literal(AuthorizeResult.DENY),
]),
),
]),
}),
),
});
@@ -45,6 +52,9 @@ export type ResourcePolicyDecision = ConditionalPolicyDecision & {
resourceRef: string;
};
/**
* @internal
*/
export class PermissionIntegrationClient {
private readonly discovery: DiscoveryService;
private readonly auth: AuthService;
@@ -74,14 +84,7 @@ export class PermissionIntegrationClient {
const response = await fetch(endpoint, {
method: 'POST',
body: JSON.stringify({
items: decisions.map(
({ id, resourceRef, resourceType, conditions }) => ({
id,
resourceRef,
resourceType,
conditions,
}),
),
items: decisions,
}),
headers: {
...(token ? { authorization: `Bearer ${token}` } : {}),
@@ -165,11 +165,7 @@ describe('createRouter', () => {
mockApplyConditions.mockResolvedValueOnce([
{
id: '123',
result: AuthorizeResult.ALLOW,
},
{
id: '123',
result: AuthorizeResult.DENY,
result: [AuthorizeResult.ALLOW, AuthorizeResult.DENY],
},
]);
@@ -198,6 +194,21 @@ describe('createRouter', () => {
],
});
expect(mockApplyConditions).toHaveBeenCalledWith(
'test-plugin',
expect.any(Object),
[
{
conditions: { params: ['abc'], rule: 'test-rule' },
id: '123',
pluginId: 'test-plugin',
resourceRefs: ['resource:1', 'resource:2'],
resourceType: 'test-resource-1',
result: 'CONDITIONAL',
},
],
);
expect(response.status).toEqual(200);
expect(policy.handle).toHaveBeenCalledWith(
@@ -26,7 +26,6 @@ import {
isResourcePermission,
PermissionAttributes,
PermissionMessageBatch,
PolicyDecision,
} from '@backstage/plugin-permission-common';
import {
ApplyConditionsRequestEntry,
@@ -125,9 +124,7 @@ const handleRequest = async (
auth: AuthService,
userInfo: UserInfoService,
): Promise<
IdentifiedPermissionMessage<
EvaluatePermissionResponse | BulkDefinitivePolicyDecision
>[]
IdentifiedPermissionMessage<InternalEvaluatePermissionResponse>[]
> => {
const applyConditionsLoaderFor = memoize((pluginId: string) => {
return new DataLoader<
@@ -182,20 +179,11 @@ const handleRequest = async (
}
if (request.resourceRefs) {
const results = await Promise.all(
request.resourceRefs.map(resourceRef =>
applyConditionsLoaderFor(decision.pluginId).load({
id: request.id,
resourceRef,
...decision,
}),
),
);
return {
return applyConditionsLoaderFor(decision.pluginId).load({
id: request.id,
result: results.map(({ result }) => result),
};
resourceRefs: request.resourceRefs,
...decision,
});
}
if (!request.resourceRef) {
@@ -254,9 +242,7 @@ export async function createRouter(
'/authorize',
async (
req: Request,
res: Response<
PermissionMessageBatch<PolicyDecision | BulkDefinitivePolicyDecision>
>,
res: Response<PermissionMessageBatch<InternalEvaluatePermissionResponse>>,
) => {
const credentials = await httpAuth.credentials(req, {
allow: ['user', 'none'],
@@ -307,6 +293,8 @@ export async function createRouter(
/**
* @internal
*/
type BulkDefinitivePolicyDecision = {
result: Array<AuthorizeResult.ALLOW | AuthorizeResult.DENY>;
};
type InternalEvaluatePermissionResponse =
| EvaluatePermissionResponse
| {
result: Array<AuthorizeResult.ALLOW | AuthorizeResult.DENY>;
};
@@ -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),
),
})),
});
},