Skip to content

POST /api/applications/:id/roles returns 422 on duplicate role; the symmetric user endpoint silently no-ops #8900

@mrprofessor

Description

@mrprofessor

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:

  1. GET /api/applications/:id/roles first and diff client-side before the POST, or
  2. Treat 422 as success and recover, or
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions