Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ dist/
.next/
.env
.env.local
.optio-run-token
*.tsbuildinfo
coverage/
coverage-report.md
Expand All @@ -14,6 +15,7 @@ apps/api/.env
.claude/
.tool-versions
.values.local.yaml
helm/optio/values-prod.yaml
.worktrees/
/issues/

Expand Down
1 change: 0 additions & 1 deletion .optio-run-token

This file was deleted.

44 changes: 22 additions & 22 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,37 +17,37 @@
"openapi:types": "openapi-typescript openapi.generated.json -o openapi.generated.d.ts"
},
"dependencies": {
"@aws-sdk/client-codecommit": "^3.1041.0",
"@aws-sdk/client-sts": "^3.1041.0",
"@aws-sdk/client-codecommit": "^3.1067.0",
"@aws-sdk/client-sts": "^3.1067.0",
"@fastify/cors": "^11.0.0",
"@fastify/formbody": "^8.0.2",
"@fastify/rate-limit": "^10.2.2",
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.5",
"@fastify/swagger-ui": "^6.0.0",
"@fastify/websocket": "^11.0.2",
"@kubernetes/client-node": "^1.4.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.56.0",
"@opentelemetry/auto-instrumentations-node": "^0.52.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.56.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.56.0",
"@opentelemetry/exporter-prometheus": "^0.56.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.56.0",
"@opentelemetry/resources": "^1.29.0",
"@opentelemetry/sdk-logs": "^0.56.0",
"@opentelemetry/sdk-metrics": "^1.29.0",
"@opentelemetry/sdk-node": "^0.56.0",
"@opentelemetry/sdk-trace-node": "^1.29.0",
"@opentelemetry/semantic-conventions": "^1.28.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.219.0",
"@opentelemetry/auto-instrumentations-node": "^0.77.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.219.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.219.0",
"@opentelemetry/exporter-prometheus": "^0.219.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.219.0",
"@opentelemetry/resources": "^2.8.0",
"@opentelemetry/sdk-logs": "^0.219.0",
"@opentelemetry/sdk-metrics": "^2.8.0",
"@opentelemetry/sdk-node": "^0.219.0",
"@opentelemetry/sdk-trace-node": "^2.8.0",
"@opentelemetry/semantic-conventions": "^1.40.0",
"@optio/agent-adapters": "workspace:*",
"@optio/container-runtime": "workspace:*",
"@optio/shared": "workspace:*",
"@optio/ticket-providers": "workspace:*",
"bullmq": "^5.52.2",
"cron-parser": "^5.5.0",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.44.2",
"fastify": "^5.3.3",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"fastify-plugin": "^5.0.1",
"fastify-type-provider-zod": "^3.0.0",
"ioredis": "^5.6.1",
Expand All @@ -59,15 +59,15 @@
"zod": "^3.25.17"
},
"devDependencies": {
"@redocly/cli": "^2.26.0",
"@redocly/cli": "^2.32.2",
"@types/node": "^22.15.0",
"@types/web-push": "^3.6.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/coverage-v8": "^3.2.6",
"aws-sdk-client-mock": "^4.1.0",
"drizzle-kit": "^0.31.1",
"drizzle-kit": "^0.31.10",
"openapi-typescript": "^7.13.0",
"tsx": "^4.19.4",
"typescript": "^5.7.0",
"vitest": "^3.2.1"
"vitest": "^3.2.6"
}
}
119 changes: 113 additions & 6 deletions apps/api/src/plugins/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,34 @@ vi.mock("../services/oauth/index.js", () => ({
getOAuthProvider: () => undefined,
}));

const mockValidateSession = vi.fn();
vi.mock("../services/session-service.js", () => ({
validateSession: () => null,
validateSession: (...args: unknown[]) => mockValidateSession(...args),
}));

const mockValidateApiKey = vi.fn();
vi.mock("../services/api-key-service.js", () => ({
validateApiKey: (...args: unknown[]) => mockValidateApiKey(...args),
}));

const mockGetUserRole = vi.fn();
const mockEnsureUserHasWorkspace = vi.fn();
vi.mock("../services/workspace-service.js", () => ({
getUserRole: () => null,
ensureUserHasWorkspace: () => "ws-1",
getUserRole: (...args: unknown[]) => mockGetUserRole(...args),
ensureUserHasWorkspace: (...args: unknown[]) => mockEnsureUserHasWorkspace(...args),
}));

const mockListSecrets = vi.fn();
vi.mock("../services/secret-service.js", () => ({
listSecrets: (...args: unknown[]) => mockListSecrets(...args),
}));

import { requireRole, isSetupComplete, resetSetupCompleteCache, isPublicRoute } from "./auth.js";
import authPlugin, {
requireRole,
isSetupComplete,
resetSetupCompleteCache,
isPublicRoute,
} from "./auth.js";

// ─── Helpers ───

Expand Down Expand Up @@ -159,6 +172,79 @@ describe("requireRole", () => {
});
});

describe("authPlugin workspace context", () => {
beforeEach(() => {
vi.clearAllMocks();
authDisabled = false;
mockValidateSession.mockResolvedValue(makeUser(null));
mockValidateApiKey.mockResolvedValue(null);
mockEnsureUserHasWorkspace.mockResolvedValue("ws-1");
});

async function buildAuthApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
await app.register(authPlugin);
app.get("/protected", async (req) => ({ user: req.user }));
await app.ready();
return app;
}

it("rejects an explicit workspace header when the user is not a member", async () => {
mockGetUserRole.mockResolvedValue(null);
const app = await buildAuthApp();

const res = await app.inject({
method: "GET",
url: "/protected",
headers: {
authorization: "Bearer session-token",
"x-workspace-id": "ws-other",
},
});

expect(res.statusCode).toBe(403);
expect(res.json().error).toBe("Forbidden: not a member of requested workspace");
expect(mockEnsureUserHasWorkspace).not.toHaveBeenCalled();
await app.close();
});

it("uses an explicit workspace header when the user is a member", async () => {
mockGetUserRole.mockResolvedValue("viewer");
const app = await buildAuthApp();

const res = await app.inject({
method: "GET",
url: "/protected",
headers: {
authorization: "Bearer session-token",
"x-workspace-id": "ws-2",
},
});

expect(res.statusCode).toBe(200);
expect(res.json().user.workspaceId).toBe("ws-2");
expect(res.json().user.workspaceRole).toBe("viewer");
await app.close();
});

it("repairs a stale saved default workspace when no explicit workspace was requested", async () => {
mockValidateSession.mockResolvedValue({ ...makeUser(null), workspaceId: "ws-stale" });
mockGetUserRole.mockResolvedValueOnce(null).mockResolvedValueOnce("member");
const app = await buildAuthApp();

const res = await app.inject({
method: "GET",
url: "/protected",
headers: { authorization: "Bearer session-token" },
});

expect(res.statusCode).toBe(200);
expect(res.json().user.workspaceId).toBe("ws-1");
expect(res.json().user.workspaceRole).toBe("member");
await app.close();
});
});

describe("isPublicRoute", () => {
// ─── Non-auth public routes ───

Expand All @@ -170,8 +256,14 @@ describe("isPublicRoute", () => {
expect(isPublicRoute("/api/setup/status")).toBe(true);
});

it("allows /api/webhooks/ prefix", () => {
expect(isPublicRoute("/api/webhooks/some-id")).toBe(true);
it("blocks outbound /api/webhooks routes", () => {
expect(isPublicRoute("/api/webhooks")).toBe(false);
expect(isPublicRoute("/api/webhooks/some-id")).toBe(false);
expect(isPublicRoute("/api/webhooks/some-id/deliveries")).toBe(false);
});

it("allows inbound /api/hooks/ prefix", () => {
expect(isPublicRoute("/api/hooks/github-push")).toBe(true);
});

it("allows /ws/ prefix", () => {
Expand All @@ -182,6 +274,21 @@ describe("isPublicRoute", () => {
expect(isPublicRoute("/api/internal/git-credentials")).toBe(true);
});

it("allows persistent agent internal routes", () => {
expect(isPublicRoute("/api/internal/persistent-agents")).toBe(true);
expect(isPublicRoute("/api/internal/persistent-agents/send")).toBe(true);
expect(isPublicRoute("/api/internal/persistent-agents/inbox?limit=20")).toBe(true);
});

it("blocks similar /api/internal/git-credentials paths", () => {
expect(isPublicRoute("/api/internal/git-credentials-extra")).toBe(false);
expect(isPublicRoute("/api/internal/git-credentials/extra")).toBe(false);
});

it("blocks similar persistent agent internal routes", () => {
expect(isPublicRoute("/api/internal/persistent-agents-extra")).toBe(false);
});

// ─── Public auth routes (OAuth flow) ───

it("allows /api/auth/providers", () => {
Expand Down
26 changes: 12 additions & 14 deletions apps/api/src/plugins/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,23 +49,17 @@ const PUBLIC_ROUTES = new Set([
"/api/health",
"/api/setup/status",
"/api/notifications/vapid-public-key",
"/api/internal/git-credentials",
"/api/internal/persistent-agents",
]);

/**
* Prefix-matched routes that are always public.
*
* /api/internal/* routes are called by agent pods which don't have session
* cookies. They authenticate via HMAC-SHA256 signatures verified in the
* route handler itself (see hmac-auth-service.ts). The Helm ingress also
* blocks /api/internal/* from public traffic as defense in depth.
* Public prefix routes must be intentionally unauthenticated. Outbound
* webhook management lives under /api/webhooks and must remain protected.
*/
const PUBLIC_PREFIXES = [
"/api/webhooks/",
"/api/hooks/",
"/ws/",
"/api/internal/git-credentials",
"/docs",
];
const PUBLIC_PREFIXES = ["/api/hooks/", "/api/internal/persistent-agents/", "/ws/", "/docs"];

/**
* Auth routes that are public (OAuth login/callback flows only).
Expand Down Expand Up @@ -181,18 +175,22 @@ async function authPlugin(app: FastifyInstance) {
}

// Resolve workspace context
const headerWorkspaceId =
const requestedWorkspaceId =
(req.headers[WORKSPACE_HEADER] as string) ??
parseCookie(req.headers.cookie, "optio_workspace");
const workspaceId = headerWorkspaceId || user.workspaceId;
const workspaceId = requestedWorkspaceId || user.workspaceId;

if (workspaceId) {
const role = await getUserRole(workspaceId, user.id);
if (role) {
user.workspaceId = workspaceId;
user.workspaceRole = role;
} else if (requestedWorkspaceId) {
return reply.status(403).send({
error: "Forbidden: not a member of requested workspace",
});
} else {
// User not a member of the requested workspace — fall back to default
// Saved default workspace is missing/stale — fall back to a valid default.
const defaultWsId = await ensureUserHasWorkspace(user.id);
const defaultRole = await getUserRole(defaultWsId, user.id);
user.workspaceId = defaultWsId;
Expand Down
Loading
Loading