Skip to content

Commit 0096509

Browse files
fix: throw conflict error for duplicate roles/permissions (#2845)
* try to load role/perm before creating to check for duplicates * fix formatting * remove duplicate file and fix log description * fix: catch duplicate key errors * fix: rename NOT_UNIQUE to CONFLICT and change tests to reflect * fix: change identity PRECONDITION_FAILED TO CONFLICT * fix: identity tests * fix: coderabbit comments * fix: adjust error messages to be more in-depth * fix: adjust tests to not match response body anymore * fix: adjust tests to log incase of mismatches * fix: adjust tests to comments * fix: add body check to createPermission and createRole --------- Co-authored-by: Oğuzhan Olguncu <[email protected]>
1 parent 9ab5c73 commit 0096509

19 files changed

+172
-260
lines changed

apps/api/src/pkg/errors/http.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const ErrorCode = z.enum([
1313
"USAGE_EXCEEDED",
1414
"DISABLED",
1515
"NOT_FOUND",
16-
"NOT_UNIQUE",
16+
"CONFLICT",
1717
"RATE_LIMITED",
1818
"UNAUTHORIZED",
1919
"PRECONDITION_FAILED",
@@ -80,7 +80,7 @@ function codeToStatus(code: z.infer<typeof ErrorCode>): StatusCode {
8080
return 404;
8181
case "METHOD_NOT_ALLOWED":
8282
return 405;
83-
case "NOT_UNIQUE":
83+
case "CONFLICT":
8484
return 409;
8585
case "DELETE_PROTECTED":
8686
case "PRECONDITION_FAILED":

apps/api/src/pkg/errors/openapi_responses.ts

+11
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ export const openApiErrorResponses = {
4646
},
4747
},
4848
},
49+
412: {
50+
description:
51+
"The requested operation cannot be completed because certain conditions were not met. This typically occurs when a required resource state or version check fails.",
52+
content: {
53+
"application/json": {
54+
schema: errorSchemaFactory(z.enum(["PRECONDITION_FAILED"])).openapi(
55+
"ErrPreconditionFailed",
56+
),
57+
},
58+
},
59+
},
4960
429: {
5061
description: `The user has sent too many requests in a given amount of time ("rate limiting")`,
5162
content: {

apps/api/src/routes/v1_identities_createIdentity.error.test.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,12 @@ describe("when identity exists already", () => {
6262
},
6363
});
6464

65-
expect(res.status).toEqual(412);
65+
expect(res.status, `expected 409, received: ${JSON.stringify(res, null, 2)}`).toEqual(409);
6666
expect(res.body).toMatchObject({
6767
error: {
68-
code: "PRECONDITION_FAILED",
69-
docs: "https://unkey.dev/docs/api-reference/errors/code/PRECONDITION_FAILED",
70-
message: "Duplicate identity",
68+
code: "CONFLICT",
69+
docs: "https://unkey.dev/docs/api-reference/errors/code/CONFLICT",
70+
message: `Identity with externalId "${externalId}" already exists in this workspace`,
7171
},
7272
});
7373
});

apps/api/src/routes/v1_identities_createIdentity.ts

+9-4
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const route = createRoute({
3030
This usually comes from your authentication provider and could be a userId, organisationId or even an email.
3131
It does not matter what you use, as long as it uniquely identifies something in your application.
3232
33-
\`externalId\`s are unique across your workspace and therefore a \`PRECONDITION_FAILED\` error is returned when you try to create duplicates.
33+
\`externalId\`s are unique across your workspace and therefore a \`CONFLICT\` error is returned when you try to create duplicates.
3434
`,
3535
example: "user_123",
3636
}),
@@ -138,10 +138,12 @@ export const registerV1IdentitiesCreateIdentity = (app: App) =>
138138
.catch((e) => {
139139
if (e instanceof DatabaseError && e.body.message.includes("Duplicate entry")) {
140140
throw new UnkeyApiError({
141-
code: "PRECONDITION_FAILED",
142-
message: "Duplicate identity",
141+
code: "CONFLICT",
142+
message: `Identity with externalId "${identity.externalId}" already exists in this workspace`,
143143
});
144144
}
145+
146+
throw e;
145147
});
146148

147149
const ratelimits = req.ratelimits
@@ -199,7 +201,10 @@ export const registerV1IdentitiesCreateIdentity = (app: App) =>
199201
},
200202
],
201203

202-
context: { location: c.get("location"), userAgent: c.get("userAgent") },
204+
context: {
205+
location: c.get("location"),
206+
userAgent: c.get("userAgent"),
207+
},
203208
})),
204209
]);
205210
})

apps/api/src/routes/v1_identities_updateIdentity.error.test.ts

+8-6
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ describe("updating ratelimits", () => {
7272
];
7373
await h.db.primary.insert(schema.ratelimits).values(ratelimits);
7474

75+
const name = "requests";
76+
7577
const res = await h.post<V1IdentitiesUpdateIdentityRequest, V1IdentitiesUpdateIdentityResponse>(
7678
{
7779
url: "/v1/identities.updateIdentity",
@@ -83,12 +85,12 @@ describe("updating ratelimits", () => {
8385
identityId: identity.id,
8486
ratelimits: [
8587
{
86-
name: "a",
88+
name,
8789
limit: 10,
8890
duration: 20000,
8991
},
9092
{
91-
name: "a",
93+
name,
9294
limit: 10,
9395
duration: 124124,
9496
},
@@ -97,12 +99,12 @@ describe("updating ratelimits", () => {
9799
},
98100
);
99101

100-
expect(res.status).toEqual(412);
102+
expect(res.status, `expected 409, received: ${JSON.stringify(res, null, 2)}`).toEqual(409);
101103
expect(res.body).toMatchObject({
102104
error: {
103-
code: "PRECONDITION_FAILED",
104-
docs: "https://unkey.dev/docs/api-reference/errors/code/PRECONDITION_FAILED",
105-
message: "ratelimit names must be unique",
105+
code: "CONFLICT",
106+
docs: "https://unkey.dev/docs/api-reference/errors/code/CONFLICT",
107+
message: `Ratelimit with name "${name}" is already defined in the request`,
106108
},
107109
});
108110
});

apps/api/src/routes/v1_identities_updateIdentity.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ export const registerV1IdentitiesUpdateIdentity = (app: App) =>
152152
for (const { name } of req.ratelimits) {
153153
if (uniqueNames.has(name)) {
154154
throw new UnkeyApiError({
155-
code: "PRECONDITION_FAILED",
156-
message: "ratelimit names must be unique",
155+
code: "CONFLICT",
156+
message: `Ratelimit with name "${name}" is already defined in the request`,
157157
});
158158
}
159159
uniqueNames.add(name);

apps/api/src/routes/v1_keys_ami.ts

-174
This file was deleted.

apps/api/src/routes/v1_keys_deleteKey.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const route = createRoute({
2626
}),
2727
permanent: z.boolean().default(false).optional().openapi({
2828
description:
29-
"By default Unkey soft deletes keys, so they may be recovered later. If you want to permanently delete it, set permanent=true. This might be necessary if you run into NOT_UNIQUE errors during key migration.",
29+
"By default Unkey soft deletes keys, so they may be recovered later. If you want to permanently delete it, set permanent=true. This might be necessary if you run into CONFLICT errors during key migration.",
3030
}),
3131
}),
3232
},

apps/api/src/routes/v1_migrations_createKey.happy.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,7 @@ test("an error rolls back and does not create any keys", async (t) => {
488488
});
489489

490490
expect(res.status).toEqual(409);
491-
expect(res.body.error.code).toEqual("NOT_UNIQUE");
491+
expect(res.body.error.code).toEqual("CONFLICT");
492492

493493
for (let i = 0; i < req.length; i++) {
494494
const key = await h.db.primary.query.keys.findFirst({

apps/api/src/routes/v1_migrations_createKey.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,7 @@ export const registerV1MigrationsCreateKeys = (app: App) =>
559559
workspaceId: authorizedWorkspaceId,
560560
});
561561
throw new UnkeyApiError({
562-
code: "NOT_UNIQUE",
562+
code: "CONFLICT",
563563
message: e.body.message,
564564
});
565565
}

apps/api/src/routes/v1_permissions_createPermission.error.test.ts

+44
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, test } from "vitest";
22

3+
import { randomUUID } from "node:crypto";
34
import { IntegrationHarness } from "src/pkg/testutil/integration-harness";
45

56
import type {
@@ -39,3 +40,46 @@ describe.each([
3940
});
4041
});
4142
});
43+
44+
test("creating the same permission twice returns conflict", async (t) => {
45+
const h = await IntegrationHarness.init(t);
46+
const root = await h.createRootKey(["rbac.*.create_permission"]);
47+
48+
const createPermissionRequest = {
49+
url: "/v1/permissions.createPermission",
50+
headers: {
51+
"Content-Type": "application/json",
52+
Authorization: `Bearer ${root.key}`,
53+
},
54+
body: {
55+
name: randomUUID(),
56+
},
57+
};
58+
59+
const successResponse = await h.post<
60+
V1PermissionsCreatePermissionRequest,
61+
V1PermissionsCreatePermissionResponse
62+
>(createPermissionRequest);
63+
64+
expect(
65+
successResponse.status,
66+
`expected 200, received: ${JSON.stringify(successResponse, null, 2)}`,
67+
).toBe(200);
68+
69+
const errorResponse = await h.post<
70+
V1PermissionsCreatePermissionRequest,
71+
V1PermissionsCreatePermissionResponse
72+
>(createPermissionRequest);
73+
74+
expect(
75+
errorResponse.status,
76+
`expected 409, received: ${JSON.stringify(errorResponse, null, 2)}`,
77+
).toBe(409);
78+
expect(errorResponse.body).toMatchObject({
79+
error: {
80+
code: "CONFLICT",
81+
docs: "https://unkey.dev/docs/api-reference/errors/code/CONFLICT",
82+
message: `Permission with name "${createPermissionRequest.body.name}" already exists in this workspace`,
83+
},
84+
});
85+
});

0 commit comments

Comments
 (0)