diff --git a/app/routes/settings/auth-keys/actions.ts b/app/routes/settings/auth-keys/actions.ts index 30e3e83..4dc21de 100644 --- a/app/routes/settings/auth-keys/actions.ts +++ b/app/routes/settings/auth-keys/actions.ts @@ -6,16 +6,18 @@ import type { Route } from "./+types/overview"; export async function authKeysAction({ request, context }: Route.ActionArgs) { const session = await context.sessions.auth(request); - const check = await context.sessions.check(request, Capabilities.generate_authkeys); + const api = context.hsApi.getRuntimeClient(session.api_key); - if (!check) { + const canGenerateAny = await context.sessions.check(request, Capabilities.generate_authkeys); + const canGenerateOwn = await context.sessions.check(request, Capabilities.generate_own_authkeys); + + if (!canGenerateAny && !canGenerateOwn) { throw data("You do not have permission to manage pre-auth keys", { status: 403, }); } const formData = await request.formData(); - const api = context.hsApi.getRuntimeClient(session.api_key); const action = formData.get("action_id")?.toString(); if (!action) { throw data("Missing `action_id` in the form data.", { @@ -38,6 +40,20 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) { }); } + // Only allow self-service users to create keys for themselves + if (!canGenerateAny && canGenerateOwn && user) { + const [targetUser] = await api.getUsers(user); + if (!targetUser) { + return data("User not found.", { status: 404 }); + } + const targetSubject = targetUser.providerId?.split("/").pop(); + if (targetSubject !== session.user.subject) { + throw data("You can only create pre-auth keys for your own user", { + status: 403, + }); + } + } + const expiry = formData.get("expiry")?.toString(); if (!expiry) { return data("Missing `expiry` in the form data.", { @@ -88,6 +104,20 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) { }); } + // Only allow self-service users to expire their own keys + if (!canGenerateAny && canGenerateOwn) { + const [targetUser] = await api.getUsers(user); + if (!targetUser) { + return data("User not found.", { status: 404 }); + } + const targetSubject = targetUser.providerId?.split("/").pop(); + if (targetSubject !== session.user.subject) { + throw data("You can only expire pre-auth keys for your own user", { + status: 403, + }); + } + } + await api.expirePreAuthKey(user, key); return data("Pre-auth key expired"); } diff --git a/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx b/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx index 6e73523..5505bed 100644 --- a/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx +++ b/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx @@ -16,16 +16,29 @@ import toast from "~/utils/toast"; interface AddAuthKeyProps { users: User[]; url: string; + selfServiceOnly: boolean; + currentSubject: string; } -export default function AddAuthKey({ users, url }: AddAuthKeyProps) { +function findCurrentUser(users: User[], subject: string): User | undefined { + return users.find((u) => u.providerId?.split("/").pop() === subject); +} + +export default function AddAuthKey({ + users, + url, + selfServiceOnly, + currentSubject, +}: AddAuthKeyProps) { const fetcher = useFetcher(); const submittingRef = useRef(false); const [isOpen, setIsOpen] = useState(false); const [reusable, setReusable] = useState(false); const [ephemeral, setEphemeral] = useState(false); const [tagOnly, setTagOnly] = useState(false); - const [userId, setUserId] = useState(users[0]?.id); + const currentUser = selfServiceOnly ? findCurrentUser(users, currentSubject) : null; + const availableUsers = selfServiceOnly && currentUser ? [currentUser] : users; + const [userId, setUserId] = useState(availableUsers[0]?.id); const [tags, setTags] = useState(""); const createdKey = fetcher.data?.success ? fetcher.data.key : null; @@ -41,7 +54,7 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) { setReusable(false); setEphemeral(false); setTagOnly(false); - setUserId(users[0]?.id); + setUserId(availableUsers[0]?.id); setTags(""); fetcher.data = undefined; } @@ -107,30 +120,38 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) { > Generate auth key -
-
- Tag-only key - - Create a key owned by ACL tags instead of a user. - + {!selfServiceOnly && ( +
+
+ Tag-only key + + Create a key owned by ACL tags instead of a user. + +
+ setTagOnly(!tagOnly)} + />
- setTagOnly(!tagOnly)} - /> -
+ )} {!tagOnly && ( ; - for (const role in iterable) { - if (iterable[role] === capabilities) { - return role as Role; - } - } + const iterable = Roles as Record; + for (const role in iterable) { + if (iterable[role] === capabilities) { + return role as Role; + } + } - return 'member'; + return "member"; } diff --git a/tests/unit/auth-keys/self-service.test.ts b/tests/unit/auth-keys/self-service.test.ts new file mode 100644 index 0000000..a79a9f0 --- /dev/null +++ b/tests/unit/auth-keys/self-service.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "vitest"; + +import { Capabilities, hasCapability, Roles } from "~/server/web/roles"; + +describe("Self-service pre-auth keys", () => { + describe("Capabilities", () => { + test("generate_own_authkeys capability exists", () => { + expect(Capabilities.generate_own_authkeys).toBeDefined(); + expect(typeof Capabilities.generate_own_authkeys).toBe("number"); + }); + + test("generate_own_authkeys is distinct from generate_authkeys", () => { + expect(Capabilities.generate_own_authkeys).not.toBe(Capabilities.generate_authkeys); + }); + }); + + describe("Auditor role", () => { + test("auditor has generate_own_authkeys", () => { + expect(hasCapability("auditor", "generate_own_authkeys")).toBe(true); + }); + + test("auditor does not have generate_authkeys", () => { + expect(hasCapability("auditor", "generate_authkeys")).toBe(false); + }); + + test("auditor has read permissions", () => { + expect(hasCapability("auditor", "read_machines")).toBe(true); + expect(hasCapability("auditor", "read_users")).toBe(true); + expect(hasCapability("auditor", "read_policy")).toBe(true); + }); + }); + + describe("Admin roles retain full access", () => { + test("owner has generate_authkeys", () => { + expect(hasCapability("owner", "generate_authkeys")).toBe(true); + }); + + test("admin has generate_authkeys", () => { + expect(hasCapability("admin", "generate_authkeys")).toBe(true); + }); + + test("it_admin has generate_authkeys", () => { + expect(hasCapability("it_admin", "generate_authkeys")).toBe(true); + }); + + test("network_admin has generate_authkeys", () => { + expect(hasCapability("network_admin", "generate_authkeys")).toBe(true); + }); + }); + + describe("Member role", () => { + test("member has no capabilities", () => { + expect(Roles.member).toBe(0); + }); + + test("member does not have generate_own_authkeys", () => { + expect(hasCapability("member", "generate_own_authkeys")).toBe(false); + }); + }); +}); + +describe("providerId subject extraction", () => { + function extractSubject(providerId: string | undefined): string | undefined { + return providerId?.split("/").pop(); + } + + test("extracts subject from oidc providerId", () => { + expect(extractSubject("oidc/abc123")).toBe("abc123"); + }); + + test("extracts subject from nested providerId", () => { + expect(extractSubject("provider/tenant/user123")).toBe("user123"); + }); + + test("handles single component providerId", () => { + expect(extractSubject("subject")).toBe("subject"); + }); + + test("returns undefined for undefined providerId", () => { + expect(extractSubject(undefined)).toBeUndefined(); + }); +});