diff --git a/app/models/ApiKey.ts b/app/models/ApiKey.ts index 70c68b48b338..6bb33992c259 100644 --- a/app/models/ApiKey.ts +++ b/app/models/ApiKey.ts @@ -16,10 +16,13 @@ class ApiKey extends ParanoidModel { @observable expiresAt?: string; - /** An optional datetime that the API key was last used at. */ + /** Timestamp that the API key was last used. */ @observable lastActiveAt?: string; + /** The user ID that the API key belongs to. */ + userId: string; + /** The plain text value of the API key, only available on creation. */ value: string; diff --git a/app/scenes/Settings/ApiKeys.tsx b/app/scenes/Settings/ApiKeys.tsx index de096ea2b3fa..42e382587200 100644 --- a/app/scenes/Settings/ApiKeys.tsx +++ b/app/scenes/Settings/ApiKeys.tsx @@ -13,12 +13,14 @@ import Text from "~/components/Text"; import { createApiKey } from "~/actions/definitions/apiKeys"; import useActionContext from "~/hooks/useActionContext"; import useCurrentTeam from "~/hooks/useCurrentTeam"; +import useCurrentUser from "~/hooks/useCurrentUser"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import ApiKeyListItem from "./components/ApiKeyListItem"; function ApiKeys() { const team = useCurrentTeam(); + const user = useCurrentUser(); const { t } = useTranslation(); const { apiKeys } = useStores(); const can = usePolicy(team); @@ -79,7 +81,8 @@ function ApiKeys() { {t("Personal keys")}} renderItem={(apiKey: ApiKey) => ( { constructor(rootStore: RootStore) { super(rootStore, ApiKey); } + + @computed + get personalApiKeys() { + const userId = this.rootStore.auth.user?.id; + return userId + ? this.orderedData.filter((key) => key.userId === userId) + : []; + } } diff --git a/server/policies/apiKey.ts b/server/policies/apiKey.ts index 7ea229ffd5fd..f51eb977d366 100644 --- a/server/policies/apiKey.ts +++ b/server/policies/apiKey.ts @@ -5,7 +5,6 @@ import { and, isOwner, isTeamModel, isTeamMutable } from "./utils"; allow(User, "createApiKey", Team, (actor, team) => and( - // isTeamModel(actor, team), isTeamMutable(actor), !actor.isViewer, @@ -16,4 +15,18 @@ allow(User, "createApiKey", Team, (actor, team) => ) ); -allow(User, ["read", "update", "delete"], ApiKey, isOwner); +allow(User, "listApiKeys", Team, (actor, team) => + and( + // + isTeamModel(actor, team), + actor.isAdmin + ) +); + +allow(User, ["read", "update", "delete"], ApiKey, (actor, apiKey) => + and( + isOwner(actor, apiKey), + actor.isAdmin || + !!actor.team?.getPreference(TeamPreference.MembersCanCreateApiKey) + ) +); diff --git a/server/policies/user.ts b/server/policies/user.ts index e7f82127ead6..e254a3be5918 100644 --- a/server/policies/user.ts +++ b/server/policies/user.ts @@ -23,7 +23,7 @@ allow(User, "inviteUser", Team, (actor, team) => ) ); -allow(User, ["update", "readDetails"], User, (actor, user) => +allow(User, ["update", "readDetails", "listApiKeys"], User, (actor, user) => or( // isTeamAdmin(actor, user), diff --git a/server/presenters/apiKey.ts b/server/presenters/apiKey.ts index d2796bd7fae4..943cf78cb83a 100644 --- a/server/presenters/apiKey.ts +++ b/server/presenters/apiKey.ts @@ -3,6 +3,7 @@ import ApiKey from "@server/models/ApiKey"; export default function presentApiKey(apiKey: ApiKey) { return { id: apiKey.id, + userId: apiKey.userId, name: apiKey.name, value: apiKey.value, last4: apiKey.last4, diff --git a/server/routes/api/apiKeys/apiKeys.test.ts b/server/routes/api/apiKeys/apiKeys.test.ts index d733b87049fa..42cc5742b801 100644 --- a/server/routes/api/apiKeys/apiKeys.test.ts +++ b/server/routes/api/apiKeys/apiKeys.test.ts @@ -1,4 +1,4 @@ -import { buildApiKey, buildUser } from "@server/test/factories"; +import { buildAdmin, buildApiKey, buildUser } from "@server/test/factories"; import { getTestServer } from "@server/test/support"; const server = getTestServer(); @@ -47,25 +47,58 @@ describe("#apiKeys.create", () => { }); describe("#apiKeys.list", () => { - it("should return api keys of a user", async () => { - const now = new Date(); + it("should return api keys of the specified user", async () => { const user = await buildUser(); - await buildApiKey({ - name: "My API Key", - userId: user.id, - expiresAt: now, + const admin = await buildAdmin({ teamId: user.teamId }); + await buildApiKey({ userId: user.id }); + + const res = await server.post("/api/apiKeys.list", { + body: { + userId: user.id, + token: admin.getJwtToken(), + }, }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + }); + + it("should return api keys of the specified user for admin", async () => { + const user = await buildUser(); + const admin = await buildAdmin({ teamId: user.teamId }); + await buildApiKey({ userId: user.id }); + await buildApiKey({ userId: admin.id }); const res = await server.post("/api/apiKeys.list", { body: { - token: user.getJwtToken(), + userId: admin.id, + token: admin.getJwtToken(), + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + }); + + it("should return api keys of all users for admin", async () => { + const admin = await buildAdmin(); + const user = await buildUser({ teamId: admin.teamId }); + await buildApiKey({ userId: admin.id }); + await buildApiKey({ userId: user.id }); + await buildApiKey(); + + const res = await server.post("/api/apiKeys.list", { + body: { + token: admin.getJwtToken(), }, }); + const body = await res.json(); expect(res.status).toEqual(200); - expect(body.data[0].name).toEqual("My API Key"); - expect(body.data[0].expiresAt).toEqual(now.toISOString()); + expect(body.data.length).toEqual(2); }); it("should require authentication", async () => { diff --git a/server/routes/api/apiKeys/apiKeys.ts b/server/routes/api/apiKeys/apiKeys.ts index 2df8b583c834..51962b5d2f8f 100644 --- a/server/routes/api/apiKeys/apiKeys.ts +++ b/server/routes/api/apiKeys/apiKeys.ts @@ -1,10 +1,11 @@ import Router from "koa-router"; +import { WhereOptions } from "sequelize"; import { UserRole } from "@shared/types"; import auth from "@server/middlewares/authentication"; import { transaction } from "@server/middlewares/transaction"; import validate from "@server/middlewares/validate"; -import { ApiKey, Event } from "@server/models"; -import { authorize } from "@server/policies"; +import { ApiKey, Event, User } from "@server/models"; +import { authorize, cannot } from "@server/policies"; import { presentApiKey } from "@server/presenters"; import { APIContext, AuthenticationType } from "@server/types"; import pagination from "../middlewares/pagination"; @@ -54,12 +55,40 @@ router.post( "apiKeys.list", auth({ role: UserRole.Member }), pagination(), - async (ctx: APIContext) => { - const { user } = ctx.state.auth; + validate(T.APIKeysListSchema), + async (ctx: APIContext) => { + const { userId } = ctx.input.body; + const actor = ctx.state.auth.user; + + let where: WhereOptions = { + teamId: actor.teamId, + }; + + if (cannot(actor, "listApiKeys", actor.team)) { + where = { + ...where, + id: actor.id, + }; + } + + if (userId) { + const user = await User.findByPk(userId); + authorize(actor, "listApiKeys", user); + + where = { + ...where, + id: userId, + }; + } + const keys = await ApiKey.findAll({ - where: { - userId: user.id, - }, + include: [ + { + model: User, + required: true, + where, + }, + ], order: [["createdAt", "DESC"]], offset: ctx.state.pagination.offset, limit: ctx.state.pagination.limit, diff --git a/server/routes/api/apiKeys/schema.ts b/server/routes/api/apiKeys/schema.ts index 25271f0816eb..a26600deb9af 100644 --- a/server/routes/api/apiKeys/schema.ts +++ b/server/routes/api/apiKeys/schema.ts @@ -12,6 +12,15 @@ export const APIKeysCreateSchema = BaseSchema.extend({ export type APIKeysCreateReq = z.infer; +export const APIKeysListSchema = BaseSchema.extend({ + body: z.object({ + /** The owner of the API key */ + userId: z.string().uuid().optional(), + }), +}); + +export type APIKeysListReq = z.infer; + export const APIKeysDeleteSchema = BaseSchema.extend({ body: z.object({ /** API Key Id */