Summary
POST /api/applications/:applicationId/roles rejects requests with application.role_exists (422) when any role in the payload is already attached to the application. The symmetric POST /api/users/:userId/roles was made idempotent in #4382 and silently filters out already-attached roles, returning 201 with addedRoleIds: [].
This asymmetry forces every Logto API client that re-applies application↔role assignments to either:
GET /api/applications/:id/roles first and diff client-side before the POST, or
- Treat 422 as success and recover, or
- Always use
PUT (which is already idempotent) — but that has destructive semantics: it removes any role not in the payload.
None of these is necessary for the user-roles endpoint.
Reproduction
# Assume an existing m2m application $APP_ID and an existing m2m role $ROLE_ID
curl -X POST .../api/applications/$APP_ID/roles \
-H 'Authorization: Bearer ...' -H 'Content-Type: application/json' \
-d "{\"roleIds\":[\"$ROLE_ID\"]}"
# → 201
curl -X POST .../api/applications/$APP_ID/roles \
-H 'Authorization: Bearer ...' -H 'Content-Type: application/json' \
-d "{\"roleIds\":[\"$ROLE_ID\"]}"
# → 422 { "code": "application.role_exists", ... }
The same two calls against POST /api/users/:userId/roles both return 201; the second one has addedRoleIds: [].
Where the asymmetry lives
Application (strict): packages/core/src/routes/applications/application-role.ts (POST handler)
for (const role of roles) {
assertThat(
!applicationRoles.some(({ roleId: _roleId }) => _roleId === role.id),
new RequestError({ code: 'application.role_exists', status: 422, roleId: role.id })
);
...
}
User (lenient): packages/core/src/routes/admin-user/role.ts (POST handler)
const existingRoleIds = new Set(usersRoles.map(({ roleId }) => roleId));
const roleIdsToAdd = roleIds.filter((id) => !existingRoleIds.has(id)); // ignore existing roles.
The user-roles version of this was changed from strict-422 to silently-filter in #4382 (commit f6caeacb5). The application-roles endpoint was introduced ~2h later the same day in #4425 (commit 9251c2bb4) with the older strict pattern, and the strict behaviour is pinned by packages/integration-tests/src/tests/api/application/application.roles.test.ts (should fail when assign duplicated role to app).
Proposed fix
Align POST /api/applications/:applicationId/roles with POST /api/users/:userId/roles:
Happy to open a PR if the direction is acceptable.
Why this matters
Idempotent role assignment is the natural primitive for any IaC-style provisioner that re-applies the same desired state on every run (Terraform-like flows, GitOps reconciliation, our internal m2m provisioner). The current 422 forces every such client to re-implement the lenient filter against Logto, even though Logto already has the right implementation — just on the wrong endpoint.
Summary
POST /api/applications/:applicationId/rolesrejects requests withapplication.role_exists(422) when any role in the payload is already attached to the application. The symmetricPOST /api/users/:userId/roleswas made idempotent in #4382 and silently filters out already-attached roles, returning 201 withaddedRoleIds: [].This asymmetry forces every Logto API client that re-applies application↔role assignments to either:
GET /api/applications/:id/rolesfirst and diff client-side before the POST, orPUT(which is already idempotent) — but that has destructive semantics: it removes any role not in the payload.None of these is necessary for the user-roles endpoint.
Reproduction
The same two calls against
POST /api/users/:userId/rolesboth return 201; the second one hasaddedRoleIds: [].Where the asymmetry lives
Application (strict):
packages/core/src/routes/applications/application-role.ts(POST handler)User (lenient):
packages/core/src/routes/admin-user/role.ts(POST handler)The user-roles version of this was changed from strict-422 to silently-filter in #4382 (commit
f6caeacb5). The application-roles endpoint was introduced ~2h later the same day in #4425 (commit9251c2bb4) with the older strict pattern, and the strict behaviour is pinned bypackages/integration-tests/src/tests/api/application/application.roles.test.ts(should fail when assign duplicated role to app).Proposed fix
Align
POST /api/applications/:applicationId/roleswithPOST /api/users/:userId/roles:{ roleIds, addedRoleIds }to match the user-endpoint response shape (added by fix(core,api): add response body toPOST/PUT /users/:userId/rolesAPIs #8192).should fail when assign duplicated role to appintegration test to assert lenient behaviour.Happy to open a PR if the direction is acceptable.
Why this matters
Idempotent role assignment is the natural primitive for any IaC-style provisioner that re-applies the same desired state on every run (Terraform-like flows, GitOps reconciliation, our internal m2m provisioner). The current 422 forces every such client to re-implement the lenient filter against Logto, even though Logto already has the right implementation — just on the wrong endpoint.