Skip to content

Commit

Permalink
Allow returning team API keys for admins from apiKeys.list (outline…
Browse files Browse the repository at this point in the history
…#7766)

* Allow returning team apiKeys.list for admins from apiKeys.list

* Filter apikeys in store
  • Loading branch information
tommoor authored Oct 14, 2024
1 parent db02b0a commit 72bfbf2
Show file tree
Hide file tree
Showing 9 changed files with 122 additions and 22 deletions.
5 changes: 4 additions & 1 deletion app/models/ApiKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 4 additions & 1 deletion app/scenes/Settings/ApiKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -79,7 +81,8 @@ function ApiKeys() {
</Text>
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
items={apiKeys.personalApiKeys}
options={{ userId: user.id }}
heading={<h2>{t("Personal keys")}</h2>}
renderItem={(apiKey: ApiKey) => (
<ApiKeyListItem
Expand Down
9 changes: 9 additions & 0 deletions app/stores/ApiKeysStore.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { computed } from "mobx";
import ApiKey from "~/models/ApiKey";
import RootStore from "./RootStore";
import Store, { RPCAction } from "./base/Store";
Expand All @@ -8,4 +9,12 @@ export default class ApiKeysStore extends Store<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)
: [];
}
}
17 changes: 15 additions & 2 deletions server/policies/apiKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
)
);
2 changes: 1 addition & 1 deletion server/policies/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions server/presenters/apiKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
53 changes: 43 additions & 10 deletions server/routes/api/apiKeys/apiKeys.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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 () => {
Expand Down
43 changes: 36 additions & 7 deletions server/routes/api/apiKeys/apiKeys.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<T.APIKeysListReq>) => {
const { userId } = ctx.input.body;
const actor = ctx.state.auth.user;

let where: WhereOptions<User> = {
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,
Expand Down
9 changes: 9 additions & 0 deletions server/routes/api/apiKeys/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ export const APIKeysCreateSchema = BaseSchema.extend({

export type APIKeysCreateReq = z.infer<typeof APIKeysCreateSchema>;

export const APIKeysListSchema = BaseSchema.extend({
body: z.object({
/** The owner of the API key */
userId: z.string().uuid().optional(),
}),
});

export type APIKeysListReq = z.infer<typeof APIKeysListSchema>;

export const APIKeysDeleteSchema = BaseSchema.extend({
body: z.object({
/** API Key Id */
Expand Down

0 comments on commit 72bfbf2

Please sign in to comment.