diff --git a/apps/dashboard/src/hooks/use-feature.ts b/apps/dashboard/src/hooks/use-feature.ts index ba6654a8..bb620ea4 100644 --- a/apps/dashboard/src/hooks/use-feature.ts +++ b/apps/dashboard/src/hooks/use-feature.ts @@ -1,19 +1,11 @@ +import type { Feature } from "@openstatus/services"; import { useQuery } from "@tanstack/react-query"; import { useTRPC } from "@/lib/trpc/client"; -/** - * Record - */ -const features = { - "slack-agent": [1, 6850], -}; - -export function useFeature(feature: keyof typeof features) { +export function useFeature(feature: Feature) { const trpc = useTRPC(); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); - if (!workspace) return false; - - return features[feature]?.includes(workspace.id) ?? false; + return workspace?.features.includes(feature) ?? false; } diff --git a/packages/api/src/router/workspace.ts b/packages/api/src/router/workspace.ts index 7825dc17..585d6e1b 100644 --- a/packages/api/src/router/workspace.ts +++ b/packages/api/src/router/workspace.ts @@ -1,4 +1,5 @@ import { Events } from "@openstatus/analytics"; +import { enabledFeatures } from "@openstatus/services"; import { getTrialDaysLeft, getWorkspaceUsage, @@ -17,6 +18,7 @@ export const workspaceRouter = createTRPCRouter({ get: protectedProcedure.query(({ ctx }) => ({ ...ctx.workspace, trialDaysLeft: getTrialDaysLeft(ctx.workspace.trialEndsAt), + features: enabledFeatures(ctx.workspace), })), usage: protectedProcedure.query(async ({ ctx }) => { diff --git a/packages/services/package.json b/packages/services/package.json index f1848723..483920fd 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -113,6 +113,10 @@ "import": "./src/private-location/index.ts", "types": "./src/private-location/index.ts" }, + "./slack-user": { + "import": "./src/slack-user/index.ts", + "types": "./src/slack-user/index.ts" + }, "./page-access": { "import": "./src/page-access/index.ts", "types": "./src/page-access/index.ts" diff --git a/packages/services/src/__tests__/features.test.ts b/packages/services/src/__tests__/features.test.ts new file mode 100644 index 00000000..3bc17ba0 --- /dev/null +++ b/packages/services/src/__tests__/features.test.ts @@ -0,0 +1,44 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { ForbiddenError } from "../errors"; +import { isFeatureEnabled, requireFeature } from "../features"; +import type { Workspace } from "../types"; + +const none = new Set(); + +describe("isFeatureEnabled", () => { + test("allowlisted workspace", () => { + expect(isFeatureEnabled({ id: 1 }, "incident-management", none)).toBe(true); + }); + + test("other workspace", () => { + expect( + isFeatureEnabled({ id: 987654321 }, "incident-management", none), + ).toBe(false); + }); + + test("env override enables it for every workspace", () => { + expect( + isFeatureEnabled( + { id: 987654321 }, + "incident-management", + new Set(["incident-management"]), + ), + ).toBe(true); + }); +}); + +describe("requireFeature", () => { + test("throws ForbiddenError when disabled", () => { + const processEnv: Record = process.env; + if (processEnv.OPENSTATUS_FEATURES?.includes("slack-agent")) return; + const workspace = { id: 987654321 } as Workspace; + expect(() => + requireFeature( + { workspace, actor: { type: "system", job: "test" } }, + "slack-agent", + ), + ).toThrow(ForbiddenError); + }); +}); diff --git a/packages/services/src/auth/__tests__/require-role.test.ts b/packages/services/src/auth/__tests__/require-role.test.ts new file mode 100644 index 00000000..a18621a5 --- /dev/null +++ b/packages/services/src/auth/__tests__/require-role.test.ts @@ -0,0 +1,97 @@ +import { db } from "@openstatus/db"; +import { + addUserToWorkspace, + createUser, +} from "@openstatus/db/src/test/factories"; +import { expect } from "@std/expect"; +import { beforeAll, describe, test } from "@std/testing/bdd"; + +import { + createWorkspaceFixture, + makeApiKeyCtx, + makeSlackCtx, + makeSystemCtx, + makeUserCtx, +} from "../../../test/helpers"; +import { ForbiddenError } from "../../errors"; +import type { Workspace } from "../../types"; +import { requireRole } from "../require-role"; + +let workspace: Workspace; +let ownerId: number; +let adminId: number; +let memberId: number; +let outsiderId: number; + +beforeAll(async () => { + const fixture = await createWorkspaceFixture("team"); + workspace = fixture.workspace; + ownerId = fixture.userId; + adminId = (await createUser()).id; + memberId = (await createUser()).id; + outsiderId = (await createUser()).id; + await addUserToWorkspace(adminId, workspace.id, "admin"); + await addUserToWorkspace(memberId, workspace.id, "member"); +}); + +describe("requireRole", () => { + test("user with an allowed role passes", async () => { + await requireRole(db, makeUserCtx(workspace, { userId: ownerId }), [ + "owner", + "admin", + ]); + await requireRole(db, makeUserCtx(workspace, { userId: adminId }), [ + "owner", + "admin", + ]); + }); + + test("member is rejected from an admin-only check", async () => { + await expect( + requireRole(db, makeUserCtx(workspace, { userId: memberId }), [ + "owner", + "admin", + ]), + ).rejects.toThrow(ForbiddenError); + }); + + test("orUserId lets the named user through", async () => { + await requireRole( + db, + makeUserCtx(workspace, { userId: memberId }), + ["owner", "admin"], + { orUserId: memberId }, + ); + }); + + test("non-member is rejected even from member-level checks", async () => { + await expect( + requireRole(db, makeUserCtx(workspace, { userId: outsiderId }), [ + "owner", + "admin", + "member", + ]), + ).rejects.toThrow(ForbiddenError); + }); + + test("slack actor resolves through its linked user", async () => { + const ctx = makeSlackCtx(workspace, { + teamId: "T1", + slackUserId: "U1", + userId: adminId, + }); + await requireRole(db, ctx, ["owner", "admin"]); + }); + + test("api key without a user passes member-level only", async () => { + const ctx = makeApiKeyCtx(workspace, { keyId: "k1" }); + await requireRole(db, ctx, ["owner", "admin", "member"]); + await expect(requireRole(db, ctx, ["owner", "admin"])).rejects.toThrow( + ForbiddenError, + ); + }); + + test("system always passes", async () => { + await requireRole(db, makeSystemCtx(workspace, { job: "test" }), ["owner"]); + }); +}); diff --git a/packages/services/src/auth/index.ts b/packages/services/src/auth/index.ts index 45388ded..08ad5e79 100644 --- a/packages/services/src/auth/index.ts +++ b/packages/services/src/auth/index.ts @@ -1,2 +1,3 @@ export { matchesScope } from "./matches-scope"; +export { requireRole } from "./require-role"; export { requireScope } from "./require-scope"; diff --git a/packages/services/src/auth/require-role.ts b/packages/services/src/auth/require-role.ts new file mode 100644 index 00000000..f00dfe88 --- /dev/null +++ b/packages/services/src/auth/require-role.ts @@ -0,0 +1,36 @@ +import type { WorkspaceRole } from "@openstatus/db/src/schema"; + +import { type DB, type ServiceContext, tryGetActorUserId } from "../context"; +import { ForbiddenError } from "../errors"; +import { getMembership } from "../member/membership"; + +/** + * Assert the actor's workspace role is one of `roles`, or that the actor is + * `orUserId`. `system` always passes; key-based actors without a linked user + * pass only member-level checks. + */ +export async function requireRole( + db: DB, + ctx: ServiceContext, + roles: ReadonlyArray, + opts: { orUserId?: number | null } = {}, +): Promise { + const { actor } = ctx; + if (actor.type === "system") return; + + const userId = tryGetActorUserId(actor); + if (userId !== null) { + if (opts.orUserId != null && opts.orUserId === userId) return; + const membership = await getMembership(db, userId, ctx.workspace.id); + if (membership && roles.includes(membership.role)) return; + throw new ForbiddenError(`Requires one of the roles: ${roles.join(", ")}`); + } + + if ( + (actor.type === "apiKey" || actor.type === "mcp") && + roles.includes("member") + ) { + return; + } + throw new ForbiddenError(`Requires one of the roles: ${roles.join(", ")}`); +} diff --git a/packages/services/src/features.ts b/packages/services/src/features.ts new file mode 100644 index 00000000..4652118b --- /dev/null +++ b/packages/services/src/features.ts @@ -0,0 +1,45 @@ +import type { ServiceContext } from "./context"; +import { ForbiddenError } from "./errors"; +import type { Workspace } from "./types"; + +const featureWorkspaces = { + "slack-agent": [1, 6850], + "incident-management": [1, 6850], +} satisfies Record>; + +export type Feature = keyof typeof featureWorkspaces; + +export const FEATURES = Object.keys(featureWorkspaces) as Feature[]; + +// `OPENSTATUS_FEATURES` (comma list) turns a feature on for every workspace: +// local dev, self-hosting and test runs. +function featuresFromEnv(): Set { + const processEnv: Record = process.env; + const raw = processEnv.OPENSTATUS_FEATURES ?? ""; + return new Set( + raw + .split(",") + .map((f) => f.trim()) + .filter(Boolean), + ); +} + +export function isFeatureEnabled( + workspace: Pick, + feature: Feature, + envFeatures: Set = featuresFromEnv(), +): boolean { + const ids: ReadonlyArray = featureWorkspaces[feature]; + return ids.includes(workspace.id) || envFeatures.has(feature); +} + +export function enabledFeatures(workspace: Pick): Feature[] { + const envFeatures = featuresFromEnv(); + return FEATURES.filter((f) => isFeatureEnabled(workspace, f, envFeatures)); +} + +export function requireFeature(ctx: ServiceContext, feature: Feature): void { + if (!isFeatureEnabled(ctx.workspace, feature)) { + throw new ForbiddenError(`Feature not enabled: ${feature}`); + } +} diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts index fa50b9f7..afe6989b 100644 --- a/packages/services/src/index.ts +++ b/packages/services/src/index.ts @@ -42,7 +42,15 @@ export { emitAudit, } from "./audit"; -export { matchesScope, requireScope } from "./auth"; +export { matchesScope, requireRole, requireScope } from "./auth"; + +export { + enabledFeatures, + FEATURES, + type Feature, + isFeatureEnabled, + requireFeature, +} from "./features"; export { LIMIT_KEYS, diff --git a/packages/services/src/slack-user/__tests__/slack-user.test.ts b/packages/services/src/slack-user/__tests__/slack-user.test.ts new file mode 100644 index 00000000..9c781fbf --- /dev/null +++ b/packages/services/src/slack-user/__tests__/slack-user.test.ts @@ -0,0 +1,148 @@ +import { eq } from "@openstatus/db"; +import { user } from "@openstatus/db/src/schema"; +import { + addUserToWorkspace, + createUser, +} from "@openstatus/db/src/test/factories"; +import { expect } from "@std/expect"; +import { beforeAll, describe, test } from "@std/testing/bdd"; + +import { + createWorkspaceFixture, + expectAuditRow, + makeSystemCtx, + makeUserCtx, + withTestTransaction, +} from "../../../test/helpers"; +import { ForbiddenError } from "../../errors"; +import type { Workspace } from "../../types"; +import { + createSlackUserMapping, + deleteSlackUserMappings, + getSlackUserMapping, +} from "../index"; + +let workspace: Workspace; +let memberId: number; + +beforeAll(async () => { + const fixture = await createWorkspaceFixture("team"); + workspace = fixture.workspace; + memberId = (await createUser()).id; + await addUserToWorkspace(memberId, workspace.id, "member"); +}); + +describe("slack user mapping", () => { + test("create + get round-trip, audited", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...makeSystemCtx(workspace, { job: "test" }), db: tx }; + const row = await createSlackUserMapping({ + ctx, + input: { teamId: "T1", slackUserId: "U1", userId: memberId }, + }); + expect( + await getSlackUserMapping({ + ctx, + input: { teamId: "T1", slackUserId: "U1" }, + }), + ).toBe(memberId); + await expectAuditRow({ + workspaceId: workspace.id, + action: "slack_user.create", + entityType: "slack_user", + entityId: row.id, + actorType: "system", + db: tx, + }); + }); + }); + + test("re-linking to another member replaces the row", async () => { + const other = await createUser(); + await addUserToWorkspace(other.id, workspace.id, "member"); + await withTestTransaction(async (tx) => { + const ctx = { ...makeSystemCtx(workspace, { job: "test" }), db: tx }; + const first = await createSlackUserMapping({ + ctx, + input: { teamId: "T2", slackUserId: "U2", userId: memberId }, + }); + await createSlackUserMapping({ + ctx, + input: { teamId: "T2", slackUserId: "U2", userId: other.id }, + }); + expect( + await getSlackUserMapping({ + ctx, + input: { teamId: "T2", slackUserId: "U2" }, + }), + ).toBe(other.id); + await expectAuditRow({ + workspaceId: workspace.id, + action: "slack_user.delete", + entityType: "slack_user", + entityId: first.id, + db: tx, + }); + }); + }); + + test("rejects a user who is not a member", async () => { + const outsider = await createUser(); + await withTestTransaction(async (tx) => { + const ctx = { + ...makeUserCtx(workspace, { userId: outsider.id }), + db: tx, + }; + await expect( + createSlackUserMapping({ + ctx, + input: { teamId: "T3", slackUserId: "U3", userId: outsider.id }, + }), + ).rejects.toThrow(ForbiddenError); + }); + }); + + test("a deleted user no longer resolves", async () => { + const gone = await createUser(); + await addUserToWorkspace(gone.id, workspace.id, "member"); + await withTestTransaction(async (tx) => { + const ctx = { ...makeSystemCtx(workspace, { job: "test" }), db: tx }; + await createSlackUserMapping({ + ctx, + input: { teamId: "T4", slackUserId: "U4", userId: gone.id }, + }); + await tx + .update(user) + .set({ deletedAt: new Date() }) + .where(eq(user.id, gone.id)); + expect( + await getSlackUserMapping({ + ctx, + input: { teamId: "T4", slackUserId: "U4" }, + }), + ).toBeNull(); + }); + }); + + test("deleteSlackUserMappings by user removes and audits each row", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...makeSystemCtx(workspace, { job: "test" }), db: tx }; + await createSlackUserMapping({ + ctx, + input: { teamId: "T5", slackUserId: "U5", userId: memberId }, + }); + const count = await deleteSlackUserMappings({ + tx, + ctx, + where: { userId: memberId }, + }); + expect(count).toBeGreaterThanOrEqual(1); + expect( + await getSlackUserMapping({ + ctx, + input: { teamId: "T5", slackUserId: "U5" }, + }), + ).toBeNull(); + }); + }); +}); diff --git a/packages/services/src/slack-user/create.ts b/packages/services/src/slack-user/create.ts new file mode 100644 index 00000000..b39a5e0e --- /dev/null +++ b/packages/services/src/slack-user/create.ts @@ -0,0 +1,62 @@ +import { and, eq } from "@openstatus/db"; +import { type SlackUser, slackUser } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { type ServiceContext, withTransaction } from "../context"; +import { ForbiddenError } from "../errors"; +import { getMembership } from "../member/membership"; +import { deleteSlackUserMappings } from "./internal"; +import { CreateSlackUserMappingInput } from "./schemas"; + +/** Link a Slack user to a workspace member; re-linking replaces the old row. */ +export async function createSlackUserMapping(args: { + ctx: ServiceContext; + input: CreateSlackUserMappingInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + const input = CreateSlackUserMappingInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const membership = await getMembership(tx, input.userId, ctx.workspace.id); + if (!membership) { + throw new ForbiddenError("User is not a member of this workspace"); + } + + const existing = await tx + .select() + .from(slackUser) + .where( + and( + eq(slackUser.workspaceId, ctx.workspace.id), + eq(slackUser.slackTeamId, input.teamId), + eq(slackUser.slackUserId, input.slackUserId), + ), + ) + .get(); + if (existing?.userId === input.userId) return existing; + if (existing) { + await deleteSlackUserMappings({ tx, ctx, where: { id: existing.id } }); + } + + const record = await tx + .insert(slackUser) + .values({ + workspaceId: ctx.workspace.id, + slackTeamId: input.teamId, + slackUserId: input.slackUserId, + userId: input.userId, + }) + .returning() + .get(); + + await emitAudit(tx, ctx, { + action: "slack_user.create", + entityType: "slack_user", + entityId: record.id, + after: record, + }); + return record; + }); +} diff --git a/packages/services/src/slack-user/get.ts b/packages/services/src/slack-user/get.ts new file mode 100644 index 00000000..c8963c85 --- /dev/null +++ b/packages/services/src/slack-user/get.ts @@ -0,0 +1,37 @@ +import { and, eq, isNull } from "@openstatus/db"; +import { slackUser, user, usersToWorkspaces } from "@openstatus/db/src/schema"; + +import { type ServiceContext, getReadDb } from "../context"; +import { GetSlackUserMappingInput } from "./schemas"; + +/** + * The openstatus user a Slack user is linked to, or `null`. Only a current, + * non-deleted member of the workspace counts, so a stale row never authorizes. + */ +export async function getSlackUserMapping(args: { + ctx: ServiceContext; + input: GetSlackUserMappingInput; +}): Promise { + const input = GetSlackUserMappingInput.parse(args.input); + const row = await getReadDb(args.ctx) + .select({ userId: slackUser.userId }) + .from(slackUser) + .innerJoin( + usersToWorkspaces, + and( + eq(usersToWorkspaces.userId, slackUser.userId), + eq(usersToWorkspaces.workspaceId, slackUser.workspaceId), + ), + ) + .innerJoin(user, eq(user.id, slackUser.userId)) + .where( + and( + eq(slackUser.workspaceId, args.ctx.workspace.id), + eq(slackUser.slackTeamId, input.teamId), + eq(slackUser.slackUserId, input.slackUserId), + isNull(user.deletedAt), + ), + ) + .get(); + return row?.userId ?? null; +} diff --git a/packages/services/src/slack-user/index.ts b/packages/services/src/slack-user/index.ts new file mode 100644 index 00000000..e83b422e --- /dev/null +++ b/packages/services/src/slack-user/index.ts @@ -0,0 +1,7 @@ +export { createSlackUserMapping } from "./create"; +export { getSlackUserMapping } from "./get"; +export { deleteSlackUserMappings } from "./internal"; +export { + CreateSlackUserMappingInput, + GetSlackUserMappingInput, +} from "./schemas"; diff --git a/packages/services/src/slack-user/internal.ts b/packages/services/src/slack-user/internal.ts new file mode 100644 index 00000000..5fe37d48 --- /dev/null +++ b/packages/services/src/slack-user/internal.ts @@ -0,0 +1,35 @@ +import { and, eq } from "@openstatus/db"; +import { slackUser } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import type { DB, ServiceContext } from "../context"; + +export async function deleteSlackUserMappings(args: { + tx: DB; + ctx: ServiceContext; + where: { userId: number } | { slackTeamId: string } | { id: number }; +}): Promise { + const { tx, ctx, where } = args; + const filter = + "userId" in where + ? eq(slackUser.userId, where.userId) + : "slackTeamId" in where + ? eq(slackUser.slackTeamId, where.slackTeamId) + : eq(slackUser.id, where.id); + + const removed = await tx + .delete(slackUser) + .where(and(eq(slackUser.workspaceId, ctx.workspace.id), filter)) + .returning(); + + for (const row of removed) { + await emitAudit(tx, ctx, { + action: "slack_user.delete", + entityType: "slack_user", + entityId: row.id, + before: row, + metadata: { slackTeamId: row.slackTeamId, userId: row.userId }, + }); + } + return removed.length; +} diff --git a/packages/services/src/slack-user/schemas.ts b/packages/services/src/slack-user/schemas.ts new file mode 100644 index 00000000..e870947b --- /dev/null +++ b/packages/services/src/slack-user/schemas.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const GetSlackUserMappingInput = z.object({ + teamId: z.string().min(1), + slackUserId: z.string().min(1), +}); +export type GetSlackUserMappingInput = z.infer; + +export const CreateSlackUserMappingInput = GetSlackUserMappingInput.extend({ + userId: z.number().int(), +}); +export type CreateSlackUserMappingInput = z.infer< + typeof CreateSlackUserMappingInput +>;