mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8cd5f3e4b | |||
| 39852470cc | |||
| 9a03e1c947 | |||
| cfaa08403a | |||
| 99bc586f34 | |||
| 75838bb311 | |||
| 8b3115be9a | |||
| 7782292500 | |||
| a7da968499 | |||
| a95005776f |
@@ -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 +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) => {
|
||||
·{" "}
|
||||
</Text>
|
||||
{apiKey.lastActiveAt && (
|
||||
<Text type={"tertiary"}>
|
||||
<Text type="tertiary">
|
||||
{t("Last used")} <Time dateTime={apiKey.lastActiveAt} addSuffix />{" "}
|
||||
·{" "}
|
||||
</Text>
|
||||
@@ -44,7 +45,20 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
{apiKey.expiresAt
|
||||
? dateToExpiry(apiKey.expiresAt, t, userLocale)
|
||||
: t("No expiry")}
|
||||
{apiKey.scope && <> · </>}
|
||||
</Text>
|
||||
{apiKey.scope && (
|
||||
<Tooltip
|
||||
content={apiKey.scope.map((s) => (
|
||||
<>
|
||||
{s}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
>
|
||||
<Text type="tertiary">{t("Restricted scope")}</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user