Compare commits

...

10 Commits

Author SHA1 Message Date
Tom Moor f8cd5f3e4b fix: Address PR feedback 2025-01-28 19:38:46 -05:00
Tom Moor 39852470cc Update API key list UI 2025-01-27 22:13:47 -05:00
Tom Moor 9a03e1c947 Update API key UI 2025-01-27 22:08:53 -05:00
Tom Moor cfaa08403a Store scopes with full url 2025-01-27 21:35:42 -05:00
Tom Moor 99bc586f34 Switch to storing array 2025-01-27 21:22:54 -05:00
Tom Moor 75838bb311 Merge branch 'main' into tom/api-scopes 2025-01-27 20:30:30 -05:00
Tom Moor 8b3115be9a test 2025-01-25 00:30:00 -05:00
Tom Moor 7782292500 Allow creation 2025-01-25 00:24:13 -05:00
Tom Moor a7da968499 Add scope restriction 2025-01-24 23:24:49 -05:00
Tom Moor a95005776f scope storage 2025-01-24 22:45:32 -05:00
13 changed files with 194 additions and 20 deletions
+6 -1
View File
@@ -6,11 +6,16 @@ import Field from "./decorators/Field";
class ApiKey extends ParanoidModel {
static modelName = "ApiKey";
/** The user chosen name of the API key. */
/** The human-readable name of this API key */
@Field
@observable
name: string;
/** A list of scopes that this API key has access to. If empty, the key has full access. */
@Field
@observable
scope?: string[];
/** An optional datetime that the API key expires. */
@Field
@observable
+22 -6
View File
@@ -22,6 +22,7 @@ type Props = {
function ApiKeyNew({ onSubmit }: Props) {
const [name, setName] = React.useState("");
const [scope, setScope] = React.useState("");
const [expiryType, setExpiryType] = React.useState<ExpiryType>(
ExpiryType.Week
);
@@ -51,6 +52,10 @@ function ApiKeyNew({ onSubmit }: Props) {
setName(event.target.value);
}, []);
const handleScopeChange = React.useCallback((event) => {
setScope(event.target.value);
}, []);
const handleExpiryTypeChange = React.useCallback((value: string) => {
const expiry = value as ExpiryType;
setExpiryType(expiry);
@@ -70,6 +75,7 @@ function ApiKeyNew({ onSubmit }: Props) {
await apiKeys.create({
name,
expiresAt: expiresAt?.toISOString(),
scope: scope ? scope.split(" ") : undefined,
});
toast.success(
t(
@@ -83,20 +89,16 @@ function ApiKeyNew({ onSubmit }: Props) {
setIsSaving(false);
}
},
[t, name, expiresAt, onSubmit, apiKeys]
[t, name, scope, expiresAt, onSubmit, apiKeys]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{t(
`Name your key something that will help you to remember it's use in the future, for example "local development" or "continuous integration".`
)}
</Text>
<Flex column>
<Input
type="text"
label={t("Name")}
placeholder={t("Development")}
onChange={handleNameChange}
value={name}
minLength={ApiKeyValidation.minNameLength}
@@ -105,6 +107,20 @@ function ApiKeyNew({ onSubmit }: Props) {
autoFocus
flex
/>
<Input
type="text"
label={t("Scopes")}
placeholder="documents.info"
onChange={handleScopeChange}
value={scope}
flex
/>
<Text type="secondary" size="small" as="p">
{t(
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access"
)}
.
</Text>
<Flex align="center" gap={16}>
<StyledExpirySelect
ariaLabel={t("Expiration")}
@@ -10,6 +10,7 @@ import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Text from "~/components/Text";
import Time from "~/components/Time";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import useUserLocale from "~/hooks/useUserLocale";
import ApiKeyMenu from "~/menus/ApiKeyMenu";
@@ -35,7 +36,7 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
&middot;{" "}
</Text>
{apiKey.lastActiveAt && (
<Text type={"tertiary"}>
<Text type="tertiary">
{t("Last used")} <Time dateTime={apiKey.lastActiveAt} addSuffix />{" "}
&middot;{" "}
</Text>
@@ -44,7 +45,20 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
{apiKey.expiresAt
? dateToExpiry(apiKey.expiresAt, t, userLocale)
: t("No expiry")}
{apiKey.scope && <> &middot; </>}
</Text>
{apiKey.scope && (
<Tooltip
content={apiKey.scope.map((s) => (
<>
{s}
<br />
</>
))}
>
<Text type="tertiary">{t("Restricted scope")}</Text>
</Tooltip>
)}
</>
);
+7 -1
View File
@@ -80,7 +80,13 @@ export default function auth(options: AuthenticationOptions = {}) {
}
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
throw AuthenticationError("Invalid API key");
throw AuthenticationError("API key is expired");
}
if (!apiKey.canAccess(ctx.request.url)) {
throw AuthenticationError(
"API key does not have access to this resource"
);
}
user = await User.findByPk(apiKey.userId, {
@@ -0,0 +1,19 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn("apiKeys", "scope", {
type: Sequelize.ARRAY(Sequelize.STRING),
allowNull: true,
}, { transaction });
});
},
async down(queryInterface) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeColumn("apiKeys", "scope", { transaction });
});
},
};
+63 -5
View File
@@ -4,26 +4,26 @@ import ApiKey from "./ApiKey";
describe("#ApiKey", () => {
describe("match", () => {
test("should match an API secret", async () => {
it("should match an API secret", async () => {
const apiKey = await buildApiKey();
expect(ApiKey.match(apiKey.value!)).toBe(true);
expect(ApiKey.match(`${randomstring.generate(38)}`)).toBe(true);
});
test("should not match non secrets", async () => {
it("should not match non secrets", async () => {
expect(ApiKey.match("123")).toBe(false);
expect(ApiKey.match("1234567890")).toBe(false);
});
});
describe("lastActiveAt", () => {
test("should update lastActiveAt", async () => {
it("should update lastActiveAt", async () => {
const apiKey = await buildApiKey();
await apiKey.updateActiveAt();
expect(apiKey.lastActiveAt).toBeTruthy();
});
test("should not update lastActiveAt within 5 minutes", async () => {
it("should not update lastActiveAt within 5 minutes", async () => {
const apiKey = await buildApiKey();
await apiKey.updateActiveAt();
expect(apiKey.lastActiveAt).toBeTruthy();
@@ -35,7 +35,7 @@ describe("#ApiKey", () => {
});
describe("findByToken", () => {
test("should find by hash", async () => {
it("should find by hash", async () => {
const apiKey = await buildApiKey({
name: "Dev",
});
@@ -44,4 +44,62 @@ describe("#ApiKey", () => {
expect(found?.last4).toEqual(apiKey.value!.slice(-4));
});
});
describe("canAccess", () => {
it("should return true for all resources if no scope", async () => {
const apiKey = await buildApiKey({
name: "Dev",
});
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/collections.create")).toBe(true);
expect(apiKey.canAccess("/api/apiKeys.list")).toBe(true);
});
it("should return false if no matching scope", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: ["/api/documents.info"],
});
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
expect(apiKey.canAccess("/api/apiKeys.list")).toBe(false);
});
it("should allow wildcard methods", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: ["/api/documents.*"],
});
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/documents.create")).toBe(true);
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
});
it("should allow wildcard namespaces", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: ["/api/*.info"],
});
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/documents.create")).toBe(false);
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
});
it("should allow multiple scopes", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: ["/api/*.info", "/api/collections.list"],
});
expect(apiKey.canAccess("/api/shares.info")).toBe(true);
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/collections.list")).toBe(true);
expect(apiKey.canAccess("/api/documents.create")).toBe(false);
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
});
});
});
+32
View File
@@ -1,4 +1,5 @@
import crypto from "crypto";
import { Matches } from "class-validator";
import { subMinutes } from "date-fns";
import randomstring from "randomstring";
import { InferAttributes, InferCreationAttributes, Op } from "sequelize";
@@ -31,6 +32,7 @@ class ApiKey extends ParanoidModel<
static eventNamespace = "api_keys";
/** The human-readable name of this API key */
@Length({
min: ApiKeyValidation.minNameLength,
max: ApiKeyValidation.maxNameLength,
@@ -39,6 +41,13 @@ class ApiKey extends ParanoidModel<
@Column
name: string;
/** A space-separated list of scopes that this API key has access to */
@Matches(/[\/\.\w\s]*/, {
each: true,
})
@Column(DataType.ARRAY(DataType.STRING))
scope: string[] | null;
/** @deprecated The plain text value of the API key, removed soon. */
@Unique
@Column
@@ -59,10 +68,12 @@ class ApiKey extends ParanoidModel<
@SkipChangeset
last4: string;
/** The date and time when this API key will expire */
@IsDate
@Column
expiresAt: Date | null;
/** The date and time when this API key was last used */
@IsDate
@Column
@SkipChangeset
@@ -156,6 +167,27 @@ class ApiKey extends ParanoidModel<
return this.save({ silent: true });
};
/** Checks if the API key has access to the given path */
canAccess = (path: string) => {
if (!this.scope) {
return true;
}
const resource = path.split("/").pop() ?? "";
const [namespace, method] = resource.split(".");
return this.scope.some((scope) => {
const [scopeNamespace, scopeMethod] = scope
.replace("/api/", "")
.split(".");
return (
scope.startsWith("/api/") &&
(namespace === scopeNamespace || scopeNamespace === "*") &&
(method === scopeMethod || scopeMethod === "*")
);
});
};
}
export default ApiKey;
+1 -4
View File
@@ -1,5 +1,4 @@
import env from "@server/env";
import { IncorrectEditionError } from "@server/errors";
import { User, Team } from "@server/models";
import Model from "@server/models/base/Model";
@@ -97,9 +96,7 @@ export function isTeamMutable(_actor: User, _model?: Model | null) {
*/
export function isCloudHosted() {
if (!env.isCloudHosted) {
throw IncorrectEditionError(
"Functionality is not available in this edition"
);
return false;
}
return true;
}
+1
View File
@@ -5,6 +5,7 @@ export default function presentApiKey(apiKey: ApiKey) {
id: apiKey.id,
userId: apiKey.userId,
name: apiKey.name,
scope: apiKey.scope,
value: apiKey.value,
last4: apiKey.last4,
createdAt: apiKey.createdAt,
+21
View File
@@ -40,6 +40,27 @@ describe("#apiKeys.create", () => {
expect(body.data.lastActiveAt).toBeNull();
});
it("should allow creating an api key with scopes", async () => {
const user = await buildUser();
const res = await server.post("/api/apiKeys.create", {
body: {
token: user.getJwtToken(),
name: "My API Key",
scope: ["/api/documents.list", "*.info", "users.*"],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("My API Key");
expect(body.data.scope).toEqual([
"/api/documents.list",
"/api/*.info",
"/api/users.*",
]);
});
it("should require authentication", async () => {
const res = await server.post("/api/apiKeys.create");
expect(res.status).toEqual(401);
+2 -1
View File
@@ -19,7 +19,7 @@ router.post(
validate(T.APIKeysCreateSchema),
transaction(),
async (ctx: APIContext<T.APIKeysCreateReq>) => {
const { name, expiresAt } = ctx.input.body;
const { name, scope, expiresAt } = ctx.input.body;
const { user } = ctx.state.auth;
authorize(user, "createApiKey", user.team);
@@ -28,6 +28,7 @@ router.post(
name,
userId: user.id,
expiresAt,
scope: scope?.map((s) => (s.startsWith("/api/") ? s : `/api/${s}`)),
});
ctx.body = {
+2
View File
@@ -7,6 +7,8 @@ export const APIKeysCreateSchema = BaseSchema.extend({
name: z.string(),
/** API Key expiry date */
expiresAt: z.coerce.date().optional(),
/** A list of scopes that this API key has access to */
scope: z.array(z.string()).optional(),
}),
});
+3 -1
View File
@@ -570,7 +570,8 @@
"invited you to": "invited you to",
"Choose a date": "Choose a date",
"API key created. Please copy the value now as it will not be shown again.": "API key created. Please copy the value now as it will not be shown again.",
"Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".": "Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".",
"Scopes": "Scopes",
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access": "Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access",
"Expiration": "Expiration",
"Never expires": "Never expires",
"7 days": "7 days",
@@ -828,6 +829,7 @@
"by {{ name }}": "by {{ name }}",
"Last used": "Last used",
"No expiry": "No expiry",
"Restricted scope": "Restricted scope",
"API key copied to clipboard": "API key copied to clipboard",
"Copied": "Copied",
"Revoking": "Revoking",