{entries.length ? (
- entries.slice(0, limit).map((entry) => (
+ entries.map((entry) => (
{entry.action}
@@ -2219,7 +2938,13 @@ function mergeRunEvents(first: RunEvent[], second: RunEvent[]) {
return Array.from(byID.values()).sort((a, b) => a.seq - b.seq);
}
-function EventList({ events, streamState }: { events: RunEvent[]; streamState?: EventStreamState }) {
+function EventList({
+ events,
+ streamState
+}: {
+ events: RunEvent[];
+ streamState?: EventStreamState;
+}) {
const { t } = useI18n();
const stateLabel = streamState === "live" ? t("runs.live") : streamState === "connecting" ? t("runs.connecting") : streamState === "fallback" ? t("runs.polling") : "";
return (
@@ -2247,13 +2972,19 @@ function labelize(value: string) {
.replace(/\b\w/g, (match) => match.toUpperCase());
}
+function workflowActionLabel(action: string, t: (key: string) => string) {
+ const key = `workflowAction.${action}`;
+ const translated = t(key);
+ return translated === key ? labelize(action) : translated;
+}
+
function pollWhileHealthy(interval: number) {
return (query: { state: { error: unknown } }) => (query.state.error ? false : interval);
}
-function formatDate(value?: string) {
+function formatDate(value: string | undefined, t: (key: string) => string) {
if (!value) {
- return "not recorded";
+ return t("common.notRecorded");
}
return new Date(value).toLocaleString();
}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index c4b4f37..2c5358e 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -6,6 +6,7 @@ const recordSchema = z.record(z.string(), z.unknown());
const projectSchema = z.object({
id: z.string(),
+ org_id: z.string().optional(),
name: z.string(),
slug: z.string(),
description: z.string(),
@@ -19,6 +20,33 @@ const organizationSchema = z.object({
created_at: z.string()
});
+const userSchema = z.object({
+ id: z.string(),
+ email: z.string(),
+ display_name: z.string(),
+ external_provider: z.string().optional(),
+ external_subject: z.string().optional(),
+ created_at: z.string()
+});
+
+const membershipSchema = z.object({
+ org_id: z.string(),
+ user_id: z.string(),
+ role: z.string(),
+ created_at: z.string()
+});
+
+const projectMembershipSchema = z.object({
+ project_id: z.string(),
+ user_id: z.string(),
+ role: z.string(),
+ project_name: z.string().optional(),
+ project_slug: z.string().optional(),
+ user_email: z.string().optional(),
+ user_name: z.string().optional(),
+ created_at: z.string()
+});
+
const repositorySchema = z.object({
id: z.string(),
project_id: z.string(),
@@ -229,30 +257,32 @@ const queueSnapshotSchema = z.object({
});
const authContextSchema = z.object({
- user: z.object({
- id: z.string(),
- email: z.string(),
- display_name: z.string(),
- created_at: z.string()
- }),
- membership: z.object({
- org_id: z.string(),
- user_id: z.string(),
- role: z.string(),
- created_at: z.string()
- }),
+ user: userSchema,
+ membership: membershipSchema,
+ project_memberships: z.array(projectMembershipSchema).default([]),
permissions: z.array(z.string())
});
+const userDirectorySchema = z.object({
+ users: z.array(userSchema),
+ memberships: z.array(membershipSchema),
+ project_memberships: z.array(projectMembershipSchema)
+});
+
const authCapabilitiesSchema = z.object({
auth_mode: z.string(),
oidc_configured: z.boolean(),
session_ttl_seconds: z.number(),
- default_role: z.string()
+ default_role: z.string(),
+ local_admin_email: z.string().optional()
});
export type Project = z.infer;
export type Organization = z.infer;
+export type User = z.infer;
+export type Membership = z.infer;
+export type ProjectMembership = z.infer;
+export type UserDirectory = z.infer;
export type Repository = z.infer;
export type Task = z.infer;
export type Run = z.infer;
@@ -324,15 +354,26 @@ export function beginOIDCLogin() {
}
export async function createBrowserSession(token: string) {
+ const trimmed = token.trim();
const response = await apiFetch("/api/v1/auth/session", {
method: "POST",
credentials: "include",
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token.trim()}` },
+ headers: { "Content-Type": "application/json", ...(trimmed ? { Authorization: `Bearer ${trimmed}` } : {}) },
body: "{}"
});
return parseResponse(response, authContextSchema);
}
+export async function loginWithPassword(input: { email: string; password: string }) {
+ const response = await apiFetch("/api/v1/auth/session", {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(input)
+ });
+ return parseResponse(response, authContextSchema);
+}
+
export function getAuthToken() {
if (typeof window === "undefined") {
return "";
@@ -369,6 +410,14 @@ export function listOrganizations() {
return getJSON("/api/v1/organizations", z.array(organizationSchema));
}
+export function listUsers() {
+ return getJSON("/api/v1/users", userDirectorySchema);
+}
+
+export function createUser(input: { email: string; display_name: string; role: string; password?: string; external_provider?: string; external_subject?: string }) {
+ return postJSON("/api/v1/users", input, authContextSchema);
+}
+
export function createOrganization(input: { name: string; slug: string }) {
return postJSON("/api/v1/organizations", input, organizationSchema);
}
@@ -381,6 +430,14 @@ export function listRepositories(projectId: string) {
return getJSON(`/api/v1/projects/${projectId}/repositories`, z.array(repositorySchema));
}
+export function listProjectMembers(projectId: string) {
+ return getJSON(`/api/v1/projects/${projectId}/members`, z.array(projectMembershipSchema));
+}
+
+export function upsertProjectMember(projectId: string, input: { user_id: string; role: string }) {
+ return postJSON(`/api/v1/projects/${projectId}/members`, input, projectMembershipSchema);
+}
+
export function createRepository(projectId: string, input: { name: string; provider: string; remote_url: string; default_branch: string }) {
return postJSON(`/api/v1/projects/${projectId}/repositories`, input, repositorySchema);
}
diff --git a/apps/web/src/lib/i18n.tsx b/apps/web/src/lib/i18n.tsx
index 23af8aa..74b2d8d 100644
--- a/apps/web/src/lib/i18n.tsx
+++ b/apps/web/src/lib/i18n.tsx
@@ -17,11 +17,14 @@ const en: Dictionary = {
"nav.nodes": "Nodes",
"nav.organizations": "Organizations",
"nav.skills": "Skills",
+ "nav.admin": "Admin",
"nav.audit": "Audit",
+ "nav.primary": "Primary navigation",
"language.label": "Language",
"language.en": "EN",
"language.zh": "中文",
"auth.session": "Session",
+ "auth.sessionUserRole": "{user} · {role}",
"auth.required": "Authentication required",
"auth.checking": "Checking",
"auth.mode": "Auth mode",
@@ -39,20 +42,37 @@ const en: Dictionary = {
"auth.loginBody": "multi-codex uses the API session and role permissions before loading protected project data.",
"auth.loginOidcReady": "OIDC login is configured for this environment.",
"auth.loginOidcMissing": "OIDC mode is enabled, but login client settings are incomplete. Use a valid bearer token or update server config.",
- "auth.loginLocal": "Local mode is active. Refresh the session to use the seeded owner account.",
+ "auth.loginLocal": "Local account login is active. The seeded admin can sign in with email and password.",
"auth.apiUnavailable": "API capabilities are unavailable. Check the API service or dev proxy.",
"auth.tokenHelp": "Paste an OIDC bearer token to exchange it for a browser session.",
"auth.sessionTtl": "Session TTL {hours}h",
"auth.defaultRole": "Default role {role}",
+ "auth.enterpriseSubtitle": "Enterprise orchestration console",
+ "auth.secureWorkspace": "Secure workspace",
+ "auth.loginHeroTitle": "Coordinate Codex work with clear account, project, and audit boundaries.",
+ "auth.loginHeroBody": "Sign in before the console loads repositories, workers, approvals, or MCP activity.",
+ "auth.assuranceEnvelope": "Task Envelope",
+ "auth.assuranceRbac": "RBAC",
+ "auth.assuranceAudit": "Audit trail",
+ "auth.openConsole": "Open console",
+ "auth.email": "Email",
+ "auth.emailPlaceholder": "name@company.com",
+ "auth.password": "Password",
+ "auth.passwordPlaceholder": "Enter password",
+ "auth.advancedLogin": "Advanced: Bearer / OIDC session exchange",
"topbar.project": "Project",
"topbar.repository": "Repository",
"topbar.branch": "Branch",
+ "topbar.projectRole": "Project role",
"topbar.refresh": "Refresh",
+ "summary.workspace": "Workspace summary",
+ "summary.projects": "Projects",
"summary.repositories": "Repositories",
"summary.tasks": "Tasks",
"summary.runs": "Runs",
"summary.queued": "Queued",
"summary.approvals": "Approvals",
+ "summary.users": "Users",
"access.lockedTitle": "Permission needed",
"access.lockedBody": "Your current role cannot access this workspace area.",
"access.missing": "Required: {permission}",
@@ -81,6 +101,23 @@ const en: Dictionary = {
"common.noRun": "no run",
"common.noEndpoint": "no endpoint",
"common.noSecretRefs": "no secret refs",
+ "common.noPath": "no path",
+ "common.slug": "Slug",
+ "common.remoteUrl": "Remote URL",
+ "common.bytes": "{count} bytes",
+ "common.yes": "yes",
+ "common.no": "no",
+ "common.ephemeral": "ephemeral",
+ "common.local": "local",
+ "common.show": "Show",
+ "common.displayCount": "Display count",
+ "common.rows": "{count} rows",
+ "common.pagination": "Pagination",
+ "common.prev": "Prev",
+ "common.next": "Next",
+ "common.previousPage": "Previous page",
+ "common.nextPage": "Next page",
+ "common.pageStatus": "{page}/{pages}",
"dashboard.workQueue": "Work Queue",
"dashboard.activeTasks": "Active Tasks",
"dashboard.capacity": "Capacity",
@@ -106,6 +143,7 @@ const en: Dictionary = {
"tasks.connectFirst": "Connect a project and repository first.",
"tasks.lifecycleHelp": "Once a task exists, this panel tracks envelope validation, scope checks, executor isolation, audit evidence, and approvals.",
"tasks.openTasks": "Open Tasks",
+ "tasks.filters": "Task filters",
"tasks.noFilterMatch": "No tasks match this filter.",
"tasks.noTasks": "No tasks yet.",
"tasks.selectTask": "Select or create a task to inspect runs, logs, scope checks, and audit state.",
@@ -135,6 +173,8 @@ const en: Dictionary = {
"tasks.latestRunEvents": "Latest Run Events",
"tasks.latestRunArtifacts": "Latest Run Artifacts",
"tasks.envelope": "Task Envelope",
+ "tasks.defaultTitle": "Implement governed lifecycle check",
+ "tasks.defaultObjective": "Run a scoped feature worker and collect verifiable artifacts.",
"lifecycle.taskEnvelope": "Task Envelope",
"lifecycle.policyValidation": "Policy Validation",
"lifecycle.executorIsolation": "Executor Isolation",
@@ -169,10 +209,13 @@ const en: Dictionary = {
"runs.live": "Live",
"runs.connecting": "Connecting",
"runs.noEvents": "No events yet.",
+ "runs.runTitle": "{role} Run",
"queue.workerQueue": "Worker Queue",
"queue.dispatch": "Dispatch",
"queue.open": "Open Queue",
"queue.backpressure": "Backpressure",
+ "queue.priorityAttempt": "priority {priority} · attempt {attempt}/{max}",
+ "queue.dispatchFailed": "Dispatch failed.",
"queue.noCapacity": "Executor capacity appears here after nodes report backpressure.",
"queue.noQueued": "No worker runs are waiting for capacity.",
"queue.noNodes": "No nodes registered.",
@@ -205,6 +248,32 @@ const en: Dictionary = {
"organizations.slug": "Slug",
"organizations.provision": "Provision",
"organizations.empty": "No organizations have been provisioned.",
+ "admin.identity": "Identity",
+ "admin.users": "Users",
+ "admin.email": "Email",
+ "admin.displayName": "Display name",
+ "admin.orgRole": "Organization role",
+ "admin.projectRole": "Project role",
+ "admin.user": "User",
+ "admin.saveUser": "Save user",
+ "admin.addMember": "Add member",
+ "admin.localUser": "local",
+ "admin.noUsers": "No users are available yet.",
+ "admin.projectCount": "{count} projects",
+ "admin.activeProject": "Active project",
+ "admin.noProject": "No project selected",
+ "admin.noMembers": "No project members yet.",
+ "admin.noProjectMembers": "Select a project to manage members.",
+ "admin.scope": "Scope",
+ "admin.projects": "Projects",
+ "admin.projectMembers": "{count} members",
+ "admin.noProjects": "No projects are available.",
+ "admin.selectedUser": "Selected user",
+ "admin.noUser": "No user selected",
+ "admin.createdAt": "Created",
+ "admin.noProjectAccess": "No project access",
+ "admin.password": "Initial or reset password",
+ "admin.passwordPlaceholder": "Leave blank to keep current password",
"approvals.center": "Approval Center",
"approvals.noReason": "No reason provided",
"approvals.approve": "Approve",
@@ -241,7 +310,41 @@ const en: Dictionary = {
"status.unverified": "unverified",
"status.docker": "docker",
"status.ssh": "ssh",
- "status.registered": "registered"
+ "status.registered": "registered",
+ "status.owner": "Owner",
+ "status.admin": "Admin",
+ "status.tech-lead": "Tech lead",
+ "status.operator": "Operator",
+ "status.reviewer": "Reviewer",
+ "status.auditor": "Auditor",
+ "status.viewer": "Viewer",
+ "status.project-admin": "Project admin",
+ "status.maintainer": "Maintainer",
+ "status.developer": "Developer",
+ "status.feature": "Feature",
+ "status.test": "Test",
+ "status.audit": "Audit",
+ "status.git-sync": "Git sync",
+ "status.git_sync": "Git sync",
+ "status.docs": "Docs",
+ "status.release": "Release",
+ "status.main": "Main",
+ "workflowAction.worker_spawn": "Spawn worker",
+ "workflowAction.worker_status": "Check worker",
+ "workflowAction.policy_validate_task": "Validate policy",
+ "workflowAction.repo_scope_check": "Run scope check",
+ "workflowAction.test_run_required": "Run tests",
+ "workflowAction.audit_run": "Run audit",
+ "workflowAction.approval_request": "Request approval",
+ "workflowAction.approval_request_pr_publish": "Request publish approval",
+ "workflowAction.git_prepare_pr": "Prepare PR",
+ "workflowAction.git_publish_pr": "Publish PR",
+ "workflowAction.resolve_blocker": "Resolve blocker",
+ "workflowAction.approval_status": "Check approvals",
+ "workflowAction.completed": "Completed",
+ "workflowAction.scope_check": "Run scope check",
+ "workflowAction.pr_prepare": "Prepare PR",
+ "workflowAction.pr_publish": "Publish PR"
};
const zh: Dictionary = {
@@ -254,11 +357,14 @@ const zh: Dictionary = {
"nav.nodes": "节点",
"nav.organizations": "组织",
"nav.skills": "技能",
+ "nav.admin": "管理",
"nav.audit": "审计",
+ "nav.primary": "主导航",
"language.label": "语言",
"language.en": "EN",
"language.zh": "中文",
"auth.session": "会话",
+ "auth.sessionUserRole": "{user} · {role}",
"auth.required": "需要登录",
"auth.checking": "检查中",
"auth.mode": "认证模式",
@@ -276,20 +382,37 @@ const zh: Dictionary = {
"auth.loginBody": "multi-codex 会先确认 API 会话和角色权限,再加载受保护的项目数据。",
"auth.loginOidcReady": "当前环境已配置 OIDC 登录。",
"auth.loginOidcMissing": "当前启用了 OIDC,但登录客户端配置不完整。请使用有效 Bearer 令牌,或更新服务端配置。",
- "auth.loginLocal": "当前为本地模式。刷新会话即可使用种子 owner 账户。",
+ "auth.loginLocal": "当前启用本地账号登录。种子管理员可使用邮箱和密码登录。",
"auth.apiUnavailable": "暂时拿不到 API 能力信息,请检查 API 服务或开发代理。",
"auth.tokenHelp": "粘贴 OIDC Bearer 令牌,可换取浏览器会话。",
"auth.sessionTtl": "会话 TTL {hours} 小时",
"auth.defaultRole": "默认角色 {role}",
+ "auth.enterpriseSubtitle": "企业级编排控制台",
+ "auth.secureWorkspace": "安全工作区",
+ "auth.loginHeroTitle": "用清晰的账号、项目和审计边界协调 Codex 工作。",
+ "auth.loginHeroBody": "登录后再加载仓库、Worker、审批和 MCP 活动数据。",
+ "auth.assuranceEnvelope": "任务 Envelope",
+ "auth.assuranceRbac": "RBAC",
+ "auth.assuranceAudit": "审计轨迹",
+ "auth.openConsole": "进入控制台",
+ "auth.email": "邮箱",
+ "auth.emailPlaceholder": "name@company.com",
+ "auth.password": "密码",
+ "auth.passwordPlaceholder": "输入密码",
+ "auth.advancedLogin": "高级:Bearer / OIDC 会话交换",
"topbar.project": "项目",
"topbar.repository": "仓库",
"topbar.branch": "分支",
+ "topbar.projectRole": "项目角色",
"topbar.refresh": "刷新",
+ "summary.workspace": "工作区概览",
+ "summary.projects": "项目",
"summary.repositories": "仓库",
"summary.tasks": "任务",
"summary.runs": "运行",
"summary.queued": "排队",
"summary.approvals": "审批",
+ "summary.users": "用户",
"access.lockedTitle": "需要权限",
"access.lockedBody": "当前角色不能访问这个工作区。",
"access.missing": "需要:{permission}",
@@ -318,6 +441,23 @@ const zh: Dictionary = {
"common.noRun": "无运行",
"common.noEndpoint": "无端点",
"common.noSecretRefs": "无密钥引用",
+ "common.noPath": "无路径",
+ "common.slug": "标识",
+ "common.remoteUrl": "远程 URL",
+ "common.bytes": "{count} 字节",
+ "common.yes": "是",
+ "common.no": "否",
+ "common.ephemeral": "临时",
+ "common.local": "本地",
+ "common.show": "显示",
+ "common.displayCount": "显示条数",
+ "common.rows": "{count} 条",
+ "common.pagination": "分页",
+ "common.prev": "上一页",
+ "common.next": "下一页",
+ "common.previousPage": "上一页",
+ "common.nextPage": "下一页",
+ "common.pageStatus": "{page}/{pages}",
"dashboard.workQueue": "工作队列",
"dashboard.activeTasks": "活跃任务",
"dashboard.capacity": "容量",
@@ -343,6 +483,7 @@ const zh: Dictionary = {
"tasks.connectFirst": "先连接项目和仓库。",
"tasks.lifecycleHelp": "任务创建后,这里会跟踪 Envelope 校验、范围检查、执行器隔离、审计证据和审批。",
"tasks.openTasks": "打开任务",
+ "tasks.filters": "任务筛选",
"tasks.noFilterMatch": "没有任务匹配当前筛选。",
"tasks.noTasks": "还没有任务。",
"tasks.selectTask": "选择或创建任务,以查看运行、日志、范围检查和审计状态。",
@@ -372,6 +513,8 @@ const zh: Dictionary = {
"tasks.latestRunEvents": "最新运行事件",
"tasks.latestRunArtifacts": "最新运行产物",
"tasks.envelope": "任务 Envelope",
+ "tasks.defaultTitle": "实现受控生命周期检查",
+ "tasks.defaultObjective": "运行受限范围的功能 worker,并收集可验证产物。",
"lifecycle.taskEnvelope": "Task Envelope",
"lifecycle.policyValidation": "策略校验",
"lifecycle.executorIsolation": "执行器隔离",
@@ -406,10 +549,13 @@ const zh: Dictionary = {
"runs.live": "实时",
"runs.connecting": "连接中",
"runs.noEvents": "还没有事件。",
+ "runs.runTitle": "{role} 运行",
"queue.workerQueue": "Worker 队列",
"queue.dispatch": "调度",
"queue.open": "打开队列",
"queue.backpressure": "背压",
+ "queue.priorityAttempt": "优先级 {priority} · 尝试 {attempt}/{max}",
+ "queue.dispatchFailed": "调度失败。",
"queue.noCapacity": "执行器上报背压后,这里会显示容量。",
"queue.noQueued": "没有等待容量的 worker 运行。",
"queue.noNodes": "还没有注册节点。",
@@ -442,6 +588,32 @@ const zh: Dictionary = {
"organizations.slug": "Slug",
"organizations.provision": "创建",
"organizations.empty": "还没有创建组织。",
+ "admin.identity": "身份",
+ "admin.users": "用户",
+ "admin.email": "邮箱",
+ "admin.displayName": "显示名称",
+ "admin.orgRole": "组织角色",
+ "admin.projectRole": "项目角色",
+ "admin.user": "用户",
+ "admin.saveUser": "保存用户",
+ "admin.addMember": "添加成员",
+ "admin.localUser": "本地",
+ "admin.noUsers": "还没有可用用户。",
+ "admin.projectCount": "{count} 个项目",
+ "admin.activeProject": "当前项目",
+ "admin.noProject": "未选择项目",
+ "admin.noMembers": "当前项目还没有成员。",
+ "admin.noProjectMembers": "选择一个项目来管理成员。",
+ "admin.scope": "范围",
+ "admin.projects": "项目",
+ "admin.projectMembers": "{count} 个成员",
+ "admin.noProjects": "还没有可用项目。",
+ "admin.selectedUser": "已选用户",
+ "admin.noUser": "未选择用户",
+ "admin.createdAt": "创建时间",
+ "admin.noProjectAccess": "暂无项目权限",
+ "admin.password": "初始或重置密码",
+ "admin.passwordPlaceholder": "留空则保留当前密码",
"approvals.center": "审批中心",
"approvals.noReason": "未提供原因",
"approvals.approve": "通过",
@@ -478,7 +650,41 @@ const zh: Dictionary = {
"status.unverified": "未验证",
"status.docker": "docker",
"status.ssh": "ssh",
- "status.registered": "已注册"
+ "status.registered": "已注册",
+ "status.owner": "所有者",
+ "status.admin": "管理员",
+ "status.tech-lead": "技术负责人",
+ "status.operator": "操作员",
+ "status.reviewer": "评审员",
+ "status.auditor": "审计员",
+ "status.viewer": "只读成员",
+ "status.project-admin": "项目管理员",
+ "status.maintainer": "维护者",
+ "status.developer": "开发者",
+ "status.feature": "功能",
+ "status.test": "测试",
+ "status.audit": "审计",
+ "status.git-sync": "Git 同步",
+ "status.git_sync": "Git 同步",
+ "status.docs": "文档",
+ "status.release": "发布",
+ "status.main": "主流程",
+ "workflowAction.worker_spawn": "启动 worker",
+ "workflowAction.worker_status": "检查 worker",
+ "workflowAction.policy_validate_task": "校验策略",
+ "workflowAction.repo_scope_check": "执行范围检查",
+ "workflowAction.test_run_required": "运行测试",
+ "workflowAction.audit_run": "运行审计",
+ "workflowAction.approval_request": "申请审批",
+ "workflowAction.approval_request_pr_publish": "申请发布审批",
+ "workflowAction.git_prepare_pr": "准备 PR",
+ "workflowAction.git_publish_pr": "发布 PR",
+ "workflowAction.resolve_blocker": "处理阻塞",
+ "workflowAction.approval_status": "检查审批",
+ "workflowAction.completed": "已完成",
+ "workflowAction.scope_check": "执行范围检查",
+ "workflowAction.pr_prepare": "准备 PR",
+ "workflowAction.pr_publish": "发布 PR"
};
const dictionaries: Record = { en, zh };
diff --git a/apps/web/src/lib/permissions.tsx b/apps/web/src/lib/permissions.tsx
index 2c5080e..5fd3c1e 100644
--- a/apps/web/src/lib/permissions.tsx
+++ b/apps/web/src/lib/permissions.tsx
@@ -3,6 +3,8 @@ import type { AuthContext } from "./api";
export type Permission =
| "*"
+ | "users:read"
+ | "users:write"
| "organizations:read"
| "organizations:write"
| "projects:read"
@@ -61,3 +63,10 @@ export function visiblePermissions(auth: AuthContext | undefined) {
}
return auth.permissions;
}
+
+export function projectRole(auth: AuthContext | undefined, projectId?: string) {
+ if (!auth || !projectId) {
+ return "";
+ }
+ return auth.project_memberships.find((membership) => membership.project_id === projectId)?.role ?? "";
+}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 4995375..50109d5 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -12,6 +12,7 @@
--surface-tint: #eef7f5;
--line: #dce3ea;
--line-soft: #e8edf2;
+ --line-strong: #c8d3dd;
--text: #17212c;
--muted: #607082;
--muted-strong: #415063;
@@ -29,6 +30,7 @@
--radius: 8px;
--radius-sm: 6px;
--shadow-soft: 0 8px 28px rgba(28, 44, 62, 0.06);
+ --shadow-panel: 0 1px 2px rgba(28, 44, 62, 0.04), 0 10px 26px rgba(28, 44, 62, 0.05);
}
* {
@@ -39,7 +41,7 @@ body {
min-width: 320px;
min-height: 100vh;
margin: 0;
- background: var(--bg);
+ background: #eef2f5;
}
a {
@@ -65,7 +67,7 @@ pre {
.shell {
display: grid;
- grid-template-columns: 228px minmax(0, 1fr);
+ grid-template-columns: 240px minmax(0, 1fr);
min-height: 100vh;
}
@@ -79,6 +81,8 @@ pre {
border-right: 1px solid var(--line);
background: var(--surface);
padding: 18px 14px;
+ overflow-y: auto;
+ scrollbar-gutter: stable;
}
.brand {
@@ -89,6 +93,10 @@ pre {
border-bottom: 1px solid var(--line-soft);
}
+.brand > div {
+ min-width: 0;
+}
+
.brand-mark {
display: grid;
place-items: center;
@@ -116,6 +124,7 @@ pre {
.brand h1 {
font-size: 18px;
line-height: 1.1;
+ white-space: nowrap;
}
.brand p {
@@ -129,6 +138,7 @@ pre {
display: grid;
gap: 3px;
margin-top: 18px;
+ padding-bottom: 12px;
}
.nav-list a {
@@ -271,7 +281,7 @@ pre {
.workspace {
min-width: 0;
- padding: 18px;
+ padding: 18px 20px 26px;
}
.topbar {
@@ -285,9 +295,9 @@ pre {
.topbar-selectors {
display: grid;
- grid-template-columns: minmax(200px, 1fr) minmax(200px, 1fr) minmax(116px, 0.45fr);
+ grid-template-columns: minmax(190px, 1fr) minmax(190px, 1fr) minmax(128px, 0.48fr);
gap: 12px;
- width: min(760px, 100%);
+ width: min(840px, 100%);
}
.topbar-selectors label,
@@ -344,7 +354,7 @@ pre {
.summary-strip {
display: grid;
- grid-template-columns: repeat(5, minmax(110px, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
@@ -369,6 +379,85 @@ pre {
min-width: 0;
}
+.login-page {
+ display: grid;
+ grid-template-columns: minmax(320px, 0.9fr) minmax(320px, 520px);
+ gap: 28px;
+ align-items: center;
+ width: min(1120px, calc(100% - 32px));
+ min-height: 100dvh;
+ margin: 0 auto;
+ padding: 32px 0;
+}
+
+.login-hero {
+ display: grid;
+ gap: 28px;
+ min-width: 0;
+}
+
+.login-brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.login-brand h1,
+.login-brand p,
+.login-copy h2,
+.login-copy p {
+ margin: 0;
+}
+
+.login-brand h1 {
+ color: var(--ink);
+ font-size: 22px;
+ line-height: 1.1;
+}
+
+.login-brand p {
+ margin-top: 4px;
+ color: var(--muted);
+ font-size: 13px;
+ font-weight: 750;
+}
+
+.login-copy {
+ display: grid;
+ gap: 12px;
+ max-width: 620px;
+}
+
+.login-copy h2 {
+ color: var(--ink);
+ font-size: 42px;
+ line-height: 1.08;
+ max-width: 12ch;
+}
+
+.login-copy p:not(.eyebrow) {
+ max-width: 560px;
+ color: var(--muted-strong);
+ font-size: 16px;
+ line-height: 1.65;
+}
+
+.login-assurance {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.login-assurance span {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ color: var(--muted-strong);
+ font-size: 12px;
+ font-weight: 850;
+ padding: 7px 9px;
+}
+
.login-panel,
.access-panel {
display: grid;
@@ -395,6 +484,8 @@ pre {
.login-meta,
.login-actions,
+.credential-login,
+.advanced-login,
.token-exchange {
display: grid;
gap: 9px;
@@ -415,6 +506,11 @@ pre {
align-items: end;
}
+.credential-login {
+ grid-template-columns: 1fr;
+}
+
+.credential-login label,
.token-exchange label {
display: grid;
gap: 6px;
@@ -423,6 +519,7 @@ pre {
font-weight: 800;
}
+.credential-login input,
.token-exchange input {
width: 100%;
min-height: 38px;
@@ -432,6 +529,18 @@ pre {
padding: 8px 10px;
}
+.advanced-login {
+ border-top: 1px solid var(--line-soft);
+ padding-top: 4px;
+}
+
+.advanced-login summary {
+ color: var(--muted-strong);
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 850;
+}
+
.access-notice {
display: grid;
gap: 6px;
@@ -453,7 +562,7 @@ pre {
.cockpit-grid {
display: grid;
- grid-template-columns: minmax(270px, 0.82fr) minmax(360px, 1.2fr) minmax(300px, 0.95fr);
+ grid-template-columns: minmax(260px, 0.78fr) minmax(380px, 1.14fr) minmax(280px, 0.9fr);
gap: 12px;
align-items: start;
}
@@ -469,24 +578,31 @@ pre {
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
- box-shadow: var(--shadow-soft);
+ box-shadow: var(--shadow-panel);
}
.cockpit-panel {
- overflow: hidden;
+ min-width: 0;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
+ flex-wrap: wrap;
gap: 12px;
min-height: 54px;
border-bottom: 1px solid var(--line-soft);
padding: 12px 14px;
}
+.panel-heading > div:first-child {
+ min-width: 0;
+}
+
.panel-heading h3 {
+ min-width: 0;
+ overflow-wrap: anywhere;
color: var(--ink);
font-size: 15px;
line-height: 1.2;
@@ -497,6 +613,122 @@ pre {
font-size: 12px;
}
+.panel-heading-actions,
+.inline-list-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: 8px;
+ min-width: 0;
+}
+
+.panel-heading-actions {
+ margin-left: auto;
+}
+
+.inline-list-toolbar {
+ justify-content: space-between;
+ border-top: 1px solid var(--line-soft);
+ padding: 10px 14px;
+}
+
+.inline-list-toolbar span {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.list-pagination-control {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 5px;
+ min-width: 0;
+}
+
+.list-limit-control {
+ display: inline-flex;
+ align-items: center;
+ flex: 0 0 auto;
+ gap: 2px;
+ min-height: 28px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: var(--surface-soft);
+ padding: 2px;
+}
+
+.list-limit-control span {
+ padding: 0 6px;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.list-limit-control button {
+ min-width: 30px;
+ min-height: 22px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--muted-strong);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 850;
+ line-height: 1;
+ padding: 4px 6px;
+}
+
+.list-limit-control button:hover,
+.list-limit-control button.active {
+ border-color: #b9d8d5;
+ background: #ffffff;
+ color: #163d3b;
+}
+
+.pager-control {
+ display: inline-flex;
+ align-items: center;
+ flex: 0 0 auto;
+ gap: 4px;
+ min-height: 28px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-sm);
+ background: #ffffff;
+ padding: 2px;
+}
+
+.pager-control button {
+ min-height: 22px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--muted-strong);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 850;
+ line-height: 1;
+ padding: 4px 6px;
+}
+
+.pager-control button:hover:not(:disabled) {
+ border-color: #c7d0da;
+ background: var(--surface-soft);
+}
+
+.pager-control button:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.pager-control span {
+ min-width: 36px;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 850;
+ text-align: center;
+}
+
.segment-group {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -542,9 +774,71 @@ pre {
display: grid;
}
+.dashboard-list,
+.bounded-list,
.cockpit-task-list {
- max-height: 360px;
overflow: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+}
+
+.dashboard-list {
+ max-height: clamp(260px, 34vh, 390px);
+}
+
+.bounded-list {
+ max-height: min(640px, calc(100vh - 260px));
+}
+
+.compact-bounded-list {
+ max-height: min(360px, calc(100vh - 340px));
+}
+
+.cockpit-task-list {
+ max-height: clamp(320px, 44vh, 520px);
+}
+
+.table-list.bounded-list,
+.task-list.bounded-list,
+.run-list.bounded-list,
+.audit-list.bounded-list,
+.project-access-list.bounded-list,
+.permission-stack.bounded-list {
+ min-height: 180px;
+}
+
+.table-list.dashboard-list,
+.audit-list.dashboard-list,
+.capacity-list.dashboard-list,
+.cockpit-task-list.dashboard-list {
+ min-height: 180px;
+}
+
+.event-list {
+ min-height: 140px;
+}
+
+.version-shell {
+ border-top: 1px solid var(--line-soft);
+}
+
+.version-shell .version-list {
+ border-top: 0;
+}
+
+.artifact-shell .inline-list-toolbar {
+ border-bottom: 1px solid var(--line-soft);
+}
+
+.artifact-list.bounded-list {
+ min-height: 140px;
+}
+
+.event-list {
+ max-height: min(420px, calc(100vh - 340px));
+ overflow: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
}
.task-row {
@@ -579,12 +873,14 @@ pre {
.task-row h4 {
margin-top: 3px;
- overflow: hidden;
color: var(--ink);
font-size: 13px;
line-height: 1.35;
- text-overflow: ellipsis;
- white-space: nowrap;
+ display: -webkit-box;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ white-space: normal;
}
.queue-stats,
@@ -653,7 +949,9 @@ pre {
cursor: pointer;
font-size: 13px;
font-weight: 800;
+ line-height: 1.2;
padding: 7px 11px;
+ text-align: center;
}
.primary-button {
@@ -705,7 +1003,7 @@ pre {
}
.lifecycle-panel {
- min-height: 720px;
+ min-height: 0;
}
.lifecycle-header {
@@ -736,12 +1034,10 @@ pre {
.task-title-line h3 {
min-width: 0;
- overflow: hidden;
+ overflow-wrap: anywhere;
color: var(--ink);
font-size: 18px;
line-height: 1.2;
- text-overflow: ellipsis;
- white-space: nowrap;
}
.task-meta,
@@ -824,6 +1120,7 @@ pre {
margin-top: 2px;
color: var(--muted);
font-size: 12px;
+ overflow-wrap: anywhere;
}
.step-rows,
@@ -854,11 +1151,9 @@ pre {
.run-facts dd,
.detail-list dd {
margin: 3px 0 0;
- overflow: hidden;
color: var(--muted-strong);
font-size: 12px;
- text-overflow: ellipsis;
- white-space: nowrap;
+ overflow-wrap: anywhere;
}
.scope-form {
@@ -995,17 +1290,126 @@ pre {
grid-template-columns: minmax(0, 1fr) auto minmax(80px, 0.65fr);
}
+.compact-evidence .data-row code,
+.compact-evidence .data-row span {
+ font-size: 11px;
+}
+
.evidence-row {
padding-block: 9px;
}
.content-grid {
display: grid;
- grid-template-columns: minmax(300px, 420px) minmax(0, 1fr);
+ grid-template-columns: minmax(300px, 400px) minmax(0, 1fr);
+ gap: 12px;
+ align-items: start;
+}
+
+.tasks-layout,
+.runs-layout {
+ grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
+}
+
+.queue-layout,
+.audit-layout {
+ grid-template-columns: minmax(0, 1fr) minmax(340px, 0.72fr);
+}
+
+.skills-layout {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.admin-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
gap: 12px;
align-items: start;
}
+.admin-main,
+.admin-side {
+ display: grid;
+ gap: 12px;
+ min-width: 0;
+}
+
+.admin-user-form {
+ grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
+ align-items: end;
+}
+
+.admin-member-form {
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+ align-items: end;
+}
+
+.admin-user-row {
+ grid-template-columns: minmax(220px, 1.3fr) auto minmax(96px, 0.45fr) minmax(120px, 0.55fr);
+}
+
+.admin-member-row {
+ grid-template-columns: minmax(220px, 1.2fr) auto minmax(150px, 0.7fr) minmax(110px, 0.45fr);
+}
+
+.project-access-list {
+ display: grid;
+ gap: 8px;
+ padding: 12px;
+}
+
+.project-access-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ border: 1px solid var(--line-soft);
+ border-radius: var(--radius-sm);
+ background: #ffffff;
+ color: inherit;
+ cursor: pointer;
+ padding: 10px;
+ text-align: left;
+}
+
+.project-access-row:hover,
+.project-access-row.is-selected {
+ border-color: #b9d8d5;
+ background: var(--surface-tint);
+}
+
+.project-access-row span,
+.project-access-row strong,
+.project-access-row code {
+ display: block;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.project-access-row strong {
+ color: var(--ink);
+ font-size: 13px;
+}
+
+.project-access-row code,
+.project-access-row em {
+ color: var(--muted);
+ font-size: 12px;
+ font-style: normal;
+}
+
+.user-access-card {
+ display: grid;
+ gap: 10px;
+}
+
+.user-project-stack {
+ padding: 0 14px 14px;
+}
+
.dashboard-grid {
display: grid;
grid-template-columns: minmax(320px, 0.9fr) minmax(320px, 1.1fr);
@@ -1023,12 +1427,12 @@ pre {
}
.inline-form {
- grid-template-columns: minmax(180px, 1fr) minmax(200px, 1.2fr) auto;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
align-items: end;
}
.node-form {
- grid-template-columns: repeat(2, minmax(180px, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
}
.node-form button {
@@ -1098,11 +1502,13 @@ pre {
.data-row span,
.data-row code {
display: block;
- overflow: hidden;
color: var(--muted);
font-size: 12px;
- text-overflow: ellipsis;
- white-space: nowrap;
+ overflow-wrap: anywhere;
+}
+
+.data-row:has(> :nth-child(5)) {
+ grid-template-columns: minmax(190px, 1.2fr) auto minmax(90px, 0.45fr) minmax(150px, 0.9fr) minmax(130px, 0.7fr);
}
.artifact-row,
@@ -1210,11 +1616,9 @@ pre {
.run-row code {
min-width: 0;
- overflow: hidden;
color: var(--muted);
font-size: 12px;
- text-overflow: ellipsis;
- white-space: nowrap;
+ overflow-wrap: anywhere;
}
.gate-strip {
@@ -1256,9 +1660,8 @@ pre {
.audit-row div,
.audit-row code {
display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+ min-width: 0;
+ overflow-wrap: anywhere;
}
.audit-row code {
@@ -1424,7 +1827,9 @@ pre {
.shell {
grid-template-columns: 216px minmax(0, 1fr);
}
+}
+@media (max-width: 1180px) {
.cockpit-grid {
grid-template-columns: minmax(270px, 0.9fr) minmax(0, 1.2fr);
}
@@ -1448,7 +1853,14 @@ pre {
}
.auth-controls {
- margin-top: 14px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ margin-top: 12px;
+ }
+
+ .language-toggle,
+ .permission-stack,
+ .auth-actions {
+ grid-column: 1 / -1;
}
.nav-list {
@@ -1473,6 +1885,9 @@ pre {
.summary-strip,
.cockpit-grid,
.content-grid,
+ .admin-grid,
+ .admin-user-form,
+ .admin-member-form,
.dashboard-grid,
.inline-form,
.node-form,
@@ -1490,6 +1905,17 @@ pre {
.actions {
justify-content: flex-start;
}
+
+ .login-page {
+ align-items: stretch;
+ grid-template-columns: 1fr;
+ min-height: auto;
+ }
+
+ .login-copy h2 {
+ max-width: 760px;
+ font-size: 34px;
+ }
}
@media (max-width: 680px) {
@@ -1497,7 +1923,10 @@ pre {
padding: 12px;
}
- .summary-strip,
+ .summary-strip {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
.queue-stats,
.step-rows,
.run-facts,
@@ -1516,10 +1945,25 @@ pre {
.run-row,
.capacity-row,
.node-row,
+ .admin-user-row,
+ .admin-member-row,
.compact-evidence .data-row {
grid-template-columns: 1fr;
}
+ .login-page {
+ width: min(100% - 24px, 560px);
+ padding: 18px 0;
+ }
+
+ .login-copy h2 {
+ font-size: 28px;
+ }
+
+ .project-access-row {
+ grid-template-columns: 1fr;
+ }
+
.task-title-line {
align-items: flex-start;
flex-direction: column;
diff --git a/cmd/api/main.go b/cmd/api/main.go
index 92429eb..ddc2b88 100644
--- a/cmd/api/main.go
+++ b/cmd/api/main.go
@@ -21,7 +21,7 @@ func main() {
log.Error("production configuration rejected", "error", err)
os.Exit(1)
}
- runtimeStore, err := store.Open(context.Background(), cfg.DatabaseURL, log)
+ runtimeStore, err := store.OpenWithConfig(context.Background(), cfg, log)
if err != nil {
log.Error("open store failed", "error", err)
os.Exit(1)
diff --git a/cmd/mcp-gateway/main.go b/cmd/mcp-gateway/main.go
index cfe1d68..6889883 100644
--- a/cmd/mcp-gateway/main.go
+++ b/cmd/mcp-gateway/main.go
@@ -21,7 +21,7 @@ func main() {
log.Error("production configuration rejected", "error", err)
os.Exit(1)
}
- runtimeStore, err := store.Open(context.Background(), cfg.DatabaseURL, log)
+ runtimeStore, err := store.OpenWithConfig(context.Background(), cfg, log)
if err != nil {
log.Error("open store failed", "error", err)
os.Exit(1)
diff --git a/deployments/docker/compose.dev.yaml b/deployments/docker/compose.dev.yaml
index e7415b0..dcbf4e8 100644
--- a/deployments/docker/compose.dev.yaml
+++ b/deployments/docker/compose.dev.yaml
@@ -32,6 +32,8 @@ services:
MULTICODEX_AUTH_SESSION_TTL: ${MULTICODEX_AUTH_SESSION_TTL:-12h}
MULTICODEX_AUTH_COOKIE_SECURE: ${MULTICODEX_AUTH_COOKIE_SECURE:-false}
MULTICODEX_AUTH_LOGIN_STATE_TTL: ${MULTICODEX_AUTH_LOGIN_STATE_TTL:-10m}
+ MULTICODEX_LOCAL_ADMIN_EMAIL: ${MULTICODEX_LOCAL_ADMIN_EMAIL:-local-dev@multi-codex.invalid}
+ MULTICODEX_LOCAL_ADMIN_PASSWORD: ${MULTICODEX_LOCAL_ADMIN_PASSWORD:-admin123}
MULTICODEX_OIDC_ISSUER: ${MULTICODEX_OIDC_ISSUER:-}
MULTICODEX_OIDC_AUDIENCE: ${MULTICODEX_OIDC_AUDIENCE:-}
MULTICODEX_OIDC_JWKS_URL: ${MULTICODEX_OIDC_JWKS_URL:-}
@@ -108,6 +110,8 @@ services:
MULTICODEX_AUTH_SESSION_TTL: ${MULTICODEX_AUTH_SESSION_TTL:-12h}
MULTICODEX_AUTH_COOKIE_SECURE: ${MULTICODEX_AUTH_COOKIE_SECURE:-false}
MULTICODEX_AUTH_LOGIN_STATE_TTL: ${MULTICODEX_AUTH_LOGIN_STATE_TTL:-10m}
+ MULTICODEX_LOCAL_ADMIN_EMAIL: ${MULTICODEX_LOCAL_ADMIN_EMAIL:-local-dev@multi-codex.invalid}
+ MULTICODEX_LOCAL_ADMIN_PASSWORD: ${MULTICODEX_LOCAL_ADMIN_PASSWORD:-admin123}
MULTICODEX_OIDC_ISSUER: ${MULTICODEX_OIDC_ISSUER:-}
MULTICODEX_OIDC_AUDIENCE: ${MULTICODEX_OIDC_AUDIENCE:-}
MULTICODEX_OIDC_JWKS_URL: ${MULTICODEX_OIDC_JWKS_URL:-}
@@ -200,6 +204,8 @@ services:
MULTICODEX_AGENTD_URL: http://worker-agentd-dev:7070
MULTICODEX_AGENTD_TOKEN: ${MULTICODEX_AGENTD_TOKEN:-}
MULTICODEX_AUTH_MODE: ${MULTICODEX_AUTH_MODE:-local}
+ MULTICODEX_LOCAL_ADMIN_EMAIL: ${MULTICODEX_LOCAL_ADMIN_EMAIL:-local-dev@multi-codex.invalid}
+ MULTICODEX_LOCAL_ADMIN_PASSWORD: ${MULTICODEX_LOCAL_ADMIN_PASSWORD:-admin123}
MULTICODEX_OIDC_ISSUER: ${MULTICODEX_OIDC_ISSUER:-}
MULTICODEX_OIDC_AUDIENCE: ${MULTICODEX_OIDC_AUDIENCE:-}
MULTICODEX_OIDC_JWKS_URL: ${MULTICODEX_OIDC_JWKS_URL:-}
diff --git a/internal/api/auth_logout_test.go b/internal/api/auth_logout_test.go
index d9dceb0..b2b4504 100644
--- a/internal/api/auth_logout_test.go
+++ b/internal/api/auth_logout_test.go
@@ -1,6 +1,7 @@
package api
import (
+ "bytes"
"crypto/rand"
"crypto/rsa"
"encoding/json"
@@ -18,7 +19,9 @@ import (
func TestAuthLogoutAuditsDecision(t *testing.T) {
st := store.NewMemoryStore()
server := NewServer(config.Config{AuthMode: "local"}, st, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ cookie := localSessionCookie(t, server.Handler())
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
+ req.AddCookie(cookie)
resp := httptest.NewRecorder()
server.Handler().ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
@@ -32,6 +35,46 @@ func TestAuthLogoutAuditsDecision(t *testing.T) {
t.Fatalf("expected api.auth_logout audit row")
}
+func TestLocalAuthRequiresBrowserSession(t *testing.T) {
+ st := store.NewMemoryStore()
+ server := NewServer(config.Config{AuthMode: "local"}, st, slog.New(slog.NewTextHandler(io.Discard, nil)))
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
+ resp := httptest.NewRecorder()
+ server.Handler().ServeHTTP(resp, req)
+ if resp.Code != http.StatusUnauthorized {
+ t.Fatalf("anonymous local auth status = %d, body = %s", resp.Code, resp.Body.String())
+ }
+
+ req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/session", bytes.NewReader([]byte(`{"email":"local-dev@multi-codex.invalid","password":"wrong-password"}`)))
+ req.Header.Set("Content-Type", "application/json")
+ resp = httptest.NewRecorder()
+ server.Handler().ServeHTTP(resp, req)
+ if resp.Code != http.StatusUnauthorized {
+ t.Fatalf("bad password session status = %d, body = %s", resp.Code, resp.Body.String())
+ }
+
+ req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/session", bytes.NewReader([]byte(`{"email":"local-dev@multi-codex.invalid","password":"admin123"}`)))
+ req.Header.Set("Content-Type", "application/json")
+ resp = httptest.NewRecorder()
+ server.Handler().ServeHTTP(resp, req)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("session status = %d, body = %s", resp.Code, resp.Body.String())
+ }
+ cookies := resp.Result().Cookies()
+ if len(cookies) != 1 || cookies[0].Name != authSessionCookieName || !cookies[0].HttpOnly {
+ t.Fatalf("session cookies = %#v", cookies)
+ }
+
+ req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
+ req.AddCookie(cookies[0])
+ resp = httptest.NewRecorder()
+ server.Handler().ServeHTTP(resp, req)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("session local auth status = %d, body = %s", resp.Code, resp.Body.String())
+ }
+}
+
func TestOIDCAuthLogoutRevokesBearerToken(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
diff --git a/internal/api/git_publish_test.go b/internal/api/git_publish_test.go
index fe389db..6f8da5c 100644
--- a/internal/api/git_publish_test.go
+++ b/internal/api/git_publish_test.go
@@ -18,8 +18,10 @@ func TestGitPublishPRAuditsCredentialMetadata(t *testing.T) {
st := store.NewMemoryStore()
task := apiGitPublishReadyTask(t, st)
server := NewServer(config.Config{GitSyncMode: "dry-run"}, st, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ cookie := localSessionCookie(t, server.Handler())
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+task.ID+"/workflow/git_publish_pr", nil)
+ req.AddCookie(cookie)
resp := httptest.NewRecorder()
server.Handler().ServeHTTP(resp, req)
if resp.Code != http.StatusCreated {
@@ -62,8 +64,10 @@ func TestGitPublishPRLiveCreatesProviderPRWithoutAutoMerge(t *testing.T) {
GitHubAPIURL: provider.URL,
GitHubToken: "gh-live-token",
}, st, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ cookie := localSessionCookie(t, server.Handler())
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+task.ID+"/workflow/git_publish_pr", nil)
+ req.AddCookie(cookie)
resp := httptest.NewRecorder()
server.Handler().ServeHTTP(resp, req)
if resp.Code != http.StatusCreated {
diff --git a/internal/api/organizations_test.go b/internal/api/organizations_test.go
index b864b0b..f84a8b6 100644
--- a/internal/api/organizations_test.go
+++ b/internal/api/organizations_test.go
@@ -17,8 +17,10 @@ import (
func TestOrganizationsAPIProvisioningAuditsCreate(t *testing.T) {
st := store.NewMemoryStore()
server := NewServer(config.Config{}, st, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ cookie := localSessionCookie(t, server.Handler())
req := httptest.NewRequest(http.MethodPost, "/api/v1/organizations", strings.NewReader(`{"name":"Engineering","slug":"engineering"}`))
+ req.AddCookie(cookie)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
server.Handler().ServeHTTP(resp, req)
@@ -34,6 +36,7 @@ func TestOrganizationsAPIProvisioningAuditsCreate(t *testing.T) {
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/organizations", nil)
+ req.AddCookie(cookie)
resp = httptest.NewRecorder()
server.Handler().ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
@@ -62,8 +65,10 @@ func TestOrganizationsAPIProvisioningAuditsCreate(t *testing.T) {
func TestOrganizationsAPIRejectsDuplicateSlug(t *testing.T) {
st := store.NewMemoryStore()
server := NewServer(config.Config{}, st, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ cookie := localSessionCookie(t, server.Handler())
for i := 0; i < 2; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/v1/organizations", strings.NewReader(`{"name":"Engineering","slug":"engineering"}`))
+ req.AddCookie(cookie)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
server.Handler().ServeHTTP(resp, req)
diff --git a/internal/api/pilot_workflow_test.go b/internal/api/pilot_workflow_test.go
index 0df274a..b3d80ad 100644
--- a/internal/api/pilot_workflow_test.go
+++ b/internal/api/pilot_workflow_test.go
@@ -315,6 +315,7 @@ func assertPilotAuditEvidence(t *testing.T, st *store.MemoryStore, dryRunID stri
func apiDo(t *testing.T, handler http.Handler, method string, path string, body any, wantStatus int, out any) {
t.Helper()
+ cookie := localSessionCookie(t, handler)
var reader io.Reader
if body != nil {
data, err := json.Marshal(body)
@@ -324,6 +325,9 @@ func apiDo(t *testing.T, handler http.Handler, method string, path string, body
reader = bytes.NewReader(data)
}
req := httptest.NewRequest(method, path, reader)
+ if cookie != nil {
+ req.AddCookie(cookie)
+ }
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
@@ -339,6 +343,24 @@ func apiDo(t *testing.T, handler http.Handler, method string, path string, body
}
}
+func localSessionCookie(t *testing.T, handler http.Handler) *http.Cookie {
+ t.Helper()
+ body := bytes.NewReader([]byte(`{"email":"local-dev@multi-codex.invalid","password":"admin123"}`))
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/session", body)
+ req.Header.Set("Content-Type", "application/json")
+ resp := httptest.NewRecorder()
+ handler.ServeHTTP(resp, req)
+ if resp.Code != http.StatusOK {
+ return nil
+ }
+ for _, cookie := range resp.Result().Cookies() {
+ if cookie.Name == authSessionCookieName {
+ return cookie
+ }
+ }
+ return nil
+}
+
func createPilotMirror(t *testing.T) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
diff --git a/internal/api/queue_test.go b/internal/api/queue_test.go
index b3e654e..1d77914 100644
--- a/internal/api/queue_test.go
+++ b/internal/api/queue_test.go
@@ -26,8 +26,10 @@ func TestQueueAPIDispatchStartsQueuedRunAndAudits(t *testing.T) {
WorktreeRoot: t.TempDir(),
RepoCacheRoot: t.TempDir(),
}, st, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ cookie := localSessionCookie(t, server.Handler())
req := httptest.NewRequest(http.MethodGet, "/api/v1/queue", nil)
+ req.AddCookie(cookie)
resp := httptest.NewRecorder()
server.Handler().ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
@@ -44,6 +46,7 @@ func TestQueueAPIDispatchStartsQueuedRunAndAudits(t *testing.T) {
}
req = httptest.NewRequest(http.MethodPost, "/api/v1/queue/dispatch", nil)
+ req.AddCookie(cookie)
resp = httptest.NewRecorder()
server.Handler().ServeHTTP(resp, req)
if resp.Code != http.StatusAccepted {
diff --git a/internal/api/resource_authorization_test.go b/internal/api/resource_authorization_test.go
index a0a946a..ca72289 100644
--- a/internal/api/resource_authorization_test.go
+++ b/internal/api/resource_authorization_test.go
@@ -59,24 +59,25 @@ func TestAPIResourceAuthorizationDeniesCrossOrganizationResources(t *testing.T)
foreignAudit := st.RecordAuditLog(domain.AuditLog{OrgID: foreignOrg.ID, ActorType: "human", ActorID: "foreign", Action: "foreign.action", ResourceType: "project", ResourceID: foreignProject.ID})
server := NewServer(config.Config{AuthMode: "local"}, st, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ cookie := localSessionCookie(t, server.Handler())
- assertForbidden(t, server, http.MethodGet, "/api/v1/projects/"+foreignProject.ID, nil)
- assertForbidden(t, server, http.MethodGet, "/api/v1/projects/"+foreignProject.ID+"/repositories", nil)
- assertForbidden(t, server, http.MethodGet, "/api/v1/projects/"+foreignProject.ID+"/agent-profiles", nil)
- assertForbidden(t, server, http.MethodGet, "/api/v1/tasks/"+foreignTask.ID, nil)
- assertForbidden(t, server, http.MethodGet, "/api/v1/tasks/"+foreignTask.ID+"/runs", nil)
- assertForbidden(t, server, http.MethodGet, "/api/v1/runs/"+foreignRun.ID, nil)
- assertForbidden(t, server, http.MethodGet, "/api/v1/runs/"+foreignRun.ID+"/artifacts", nil)
- assertForbidden(t, server, http.MethodGet, "/api/v1/artifacts/"+foreignArtifact.ID+"/content", nil)
- assertForbidden(t, server, http.MethodPost, "/api/v1/approvals/"+foreignApproval.ID+"/decision", []byte(`{"status":"approved"}`))
- assertForbidden(t, server, http.MethodPost, "/api/v1/executor-nodes/"+foreignNode.ID+"/verify-host-key", []byte(`{"observed_fingerprint":"SHA256:test"}`))
- assertForbidden(t, server, http.MethodGet, "/api/v1/skills/"+foreignSkill.ID+"/versions", nil)
-
- assertListDoesNotContain(t, server, "/api/v1/projects", foreignProject.ID)
- assertListDoesNotContain(t, server, "/api/v1/approvals", foreignApproval.ID)
- assertListDoesNotContain(t, server, "/api/v1/executor-nodes", foreignNode.ID)
- assertListDoesNotContain(t, server, "/api/v1/skills", foreignSkill.ID)
- assertListDoesNotContain(t, server, "/api/v1/audit-logs", foreignAudit.ID)
+ assertForbidden(t, server, http.MethodGet, "/api/v1/projects/"+foreignProject.ID, nil, cookie)
+ assertForbidden(t, server, http.MethodGet, "/api/v1/projects/"+foreignProject.ID+"/repositories", nil, cookie)
+ assertForbidden(t, server, http.MethodGet, "/api/v1/projects/"+foreignProject.ID+"/agent-profiles", nil, cookie)
+ assertForbidden(t, server, http.MethodGet, "/api/v1/tasks/"+foreignTask.ID, nil, cookie)
+ assertForbidden(t, server, http.MethodGet, "/api/v1/tasks/"+foreignTask.ID+"/runs", nil, cookie)
+ assertForbidden(t, server, http.MethodGet, "/api/v1/runs/"+foreignRun.ID, nil, cookie)
+ assertForbidden(t, server, http.MethodGet, "/api/v1/runs/"+foreignRun.ID+"/artifacts", nil, cookie)
+ assertForbidden(t, server, http.MethodGet, "/api/v1/artifacts/"+foreignArtifact.ID+"/content", nil, cookie)
+ assertForbidden(t, server, http.MethodPost, "/api/v1/approvals/"+foreignApproval.ID+"/decision", []byte(`{"status":"approved"}`), cookie)
+ assertForbidden(t, server, http.MethodPost, "/api/v1/executor-nodes/"+foreignNode.ID+"/verify-host-key", []byte(`{"observed_fingerprint":"SHA256:test"}`), cookie)
+ assertForbidden(t, server, http.MethodGet, "/api/v1/skills/"+foreignSkill.ID+"/versions", nil, cookie)
+
+ assertListDoesNotContain(t, server, "/api/v1/projects", foreignProject.ID, cookie)
+ assertListDoesNotContain(t, server, "/api/v1/approvals", foreignApproval.ID, cookie)
+ assertListDoesNotContain(t, server, "/api/v1/executor-nodes", foreignNode.ID, cookie)
+ assertListDoesNotContain(t, server, "/api/v1/skills", foreignSkill.ID, cookie)
+ assertListDoesNotContain(t, server, "/api/v1/audit-logs", foreignAudit.ID, cookie)
var denied bool
for _, entry := range st.ListAuditLogs() {
@@ -133,13 +134,125 @@ func TestAPIViewerCannotMutateResources(t *testing.T) {
}
}
-func assertForbidden(t *testing.T, server *Server, method string, path string, body []byte) {
+func TestAPIProjectMembershipScopesProjectAccess(t *testing.T) {
+ st := store.NewMemoryStore()
+ member, err := st.UpsertUser(domain.User{Email: "dev@example.com", DisplayName: "Dev User"}, "org_default", "viewer")
+ if err != nil {
+ t.Fatal(err)
+ }
+ allowedProject := st.CreateProject(domain.Project{OrgID: "org_default", Name: "Allowed Project", Slug: "allowed-project"})
+ allowedRepo := st.CreateRepository(domain.Repository{ProjectID: allowedProject.ID, Name: "repo", Provider: "local", RemoteURL: "file:///allowed.git"})
+ allowedTask := st.CreateTask(domain.TaskEnvelope{
+ TaskID: "ALLOWED-1",
+ ProjectID: allowedProject.ID,
+ RepositoryID: allowedRepo.ID,
+ Title: "Allowed task",
+ Role: "feature",
+ Skill: "company-feature-worker",
+ AgentProfile: "feature-worker-go-node",
+ Executor: "docker",
+ AllowedPaths: []string{"internal/**"},
+ ForbiddenPaths: []string{".env*"},
+ })
+ deniedProject := st.CreateProject(domain.Project{OrgID: "org_default", Name: "Denied Project", Slug: "denied-project"})
+ if _, err := st.UpsertProjectMembership(domain.ProjectMembership{ProjectID: allowedProject.ID, UserID: member.User.ID, Role: "developer"}); err != nil {
+ t.Fatal(err)
+ }
+ memberAuth, err := st.UpsertUser(domain.User{Email: "dev@example.com", DisplayName: "Dev User"}, "org_default", "viewer")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg := config.Config{AuthMode: "local"}
+ server := NewServer(cfg, authOverrideStore{Store: st, auth: memberAuth}, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ cookie := localSessionCookie(t, server.Handler())
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+allowedProject.ID+"/tasks", nil)
+ req.AddCookie(cookie)
+ resp := httptest.NewRecorder()
+ server.Handler().ServeHTTP(resp, req)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("allowed project status = %d, body = %s", resp.Code, resp.Body.String())
+ }
+ if !strings.Contains(resp.Body.String(), allowedTask.ID) {
+ t.Fatalf("allowed task missing: %s", resp.Body.String())
+ }
+
+ assertForbidden(t, server, http.MethodGet, "/api/v1/projects/"+deniedProject.ID, nil, cookie)
+ assertListDoesNotContain(t, server, "/api/v1/projects", deniedProject.ID, cookie)
+}
+
+func TestAPIProjectAdminCannotCreateOrganizationProject(t *testing.T) {
+ st := store.NewMemoryStore()
+ member, err := st.UpsertUser(domain.User{Email: "project-admin@example.com", DisplayName: "Project Admin"}, "org_default", "viewer")
+ if err != nil {
+ t.Fatal(err)
+ }
+ project := st.CreateProject(domain.Project{OrgID: "org_default", Name: "Scoped Project", Slug: "scoped-project"})
+ if _, err := st.UpsertProjectMembership(domain.ProjectMembership{ProjectID: project.ID, UserID: member.User.ID, Role: "project_admin"}); err != nil {
+ t.Fatal(err)
+ }
+ memberAuth, err := st.UpsertUser(domain.User{Email: "project-admin@example.com", DisplayName: "Project Admin"}, "org_default", "viewer")
+ if err != nil {
+ t.Fatal(err)
+ }
+ server := NewServer(config.Config{AuthMode: "local"}, authOverrideStore{Store: st, auth: memberAuth}, slog.New(slog.NewTextHandler(io.Discard, nil)))
+ cookie := localSessionCookie(t, server.Handler())
+
+ assertForbidden(t, server, http.MethodPost, "/api/v1/projects", []byte(`{"name":"Should Not Exist","slug":"should-not-exist"}`), cookie)
+}
+
+func TestAdminUsersAndProjectMembersAPI(t *testing.T) {
+ st := store.NewMemoryStore()
+ server := NewServer(config.Config{AuthMode: "local"}, st, slog.New(slog.NewTextHandler(io.Discard, nil)))
+
+ var auth domain.AuthContext
+ apiDo(t, server.Handler(), http.MethodPost, "/api/v1/users", map[string]any{
+ "email": "admin-target@example.com",
+ "display_name": "Admin Target",
+ "role": "viewer",
+ }, http.StatusCreated, &auth)
+ if auth.User.ID == "" || auth.Membership.Role != "viewer" {
+ t.Fatalf("created auth = %#v", auth)
+ }
+
+ project := st.CreateProject(domain.Project{OrgID: "org_default", Name: "Enterprise Project", Slug: "enterprise-project"})
+ var membership domain.ProjectMembership
+ apiDo(t, server.Handler(), http.MethodPost, "/api/v1/projects/"+project.ID+"/members", map[string]any{
+ "user_id": auth.User.ID,
+ "role": "maintainer",
+ }, http.StatusCreated, &membership)
+ if membership.ProjectID != project.ID || membership.UserID != auth.User.ID || membership.Role != "maintainer" {
+ t.Fatalf("membership = %#v", membership)
+ }
+}
+
+type authOverrideStore struct {
+ store.Store
+ auth domain.AuthContext
+}
+
+func (s authOverrideStore) GetAuthContext() domain.AuthContext {
+ return s.auth
+}
+
+func (s authOverrideStore) GetUserByEmail(email string) (domain.AuthContext, string, error) {
+ _, passwordHash, err := s.Store.GetUserByEmail(email)
+ if err != nil {
+ return domain.AuthContext{}, "", err
+ }
+ return s.auth, passwordHash, nil
+}
+
+func assertForbidden(t *testing.T, server *Server, method string, path string, body []byte, cookie *http.Cookie) {
t.Helper()
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req := httptest.NewRequest(method, path, reader)
+ if cookie != nil {
+ req.AddCookie(cookie)
+ }
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
@@ -150,9 +263,12 @@ func assertForbidden(t *testing.T, server *Server, method string, path string, b
}
}
-func assertListDoesNotContain(t *testing.T, server *Server, path string, forbiddenID string) {
+func assertListDoesNotContain(t *testing.T, server *Server, path string, forbiddenID string, cookie *http.Cookie) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
+ if cookie != nil {
+ req.AddCookie(cookie)
+ }
resp := httptest.NewRecorder()
server.Handler().ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
diff --git a/internal/api/router.go b/internal/api/router.go
index 308d623..cfed829 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -100,6 +100,8 @@ func (s *Server) routeAPI(w http.ResponseWriter, r *http.Request) {
s.authLogout(w, r)
case len(parts) == 3 && parts[0] == "auth" && parts[1] == "backchannel" && parts[2] == "logout":
s.authBackchannelLogout(w, r)
+ case len(parts) == 1 && parts[0] == "users":
+ s.users(w, r)
case len(parts) == 1 && parts[0] == "organizations":
s.organizations(w, r)
case len(parts) == 1 && parts[0] == "projects":
@@ -110,6 +112,8 @@ func (s *Server) routeAPI(w http.ResponseWriter, r *http.Request) {
s.queueDispatch(w, r)
case len(parts) == 2 && parts[0] == "projects":
s.project(w, r, parts[1])
+ case len(parts) == 3 && parts[0] == "projects" && parts[2] == "members":
+ s.projectMembers(w, r, parts[1])
case len(parts) == 3 && parts[0] == "projects" && parts[2] == "repositories":
s.repositories(w, r, parts[1])
case len(parts) == 3 && parts[0] == "projects" && parts[2] == "agent-profiles":
@@ -592,6 +596,7 @@ func (s *Server) authCapabilities(w http.ResponseWriter, r *http.Request) {
"oidc_configured": oidcConfigured,
"session_ttl_seconds": int64(s.cfg.AuthSessionTTL.Seconds()),
"default_role": s.cfg.OIDCDefaultRole,
+ "local_admin_email": localAdminEmail(s.cfg.LocalAdminEmail),
})
}
@@ -769,7 +774,30 @@ func (s *Server) authSession(w http.ResponseWriter, r *http.Request) {
return
}
if !strings.EqualFold(s.cfg.AuthMode, "oidc") {
- auth := s.store.GetAuthContext()
+ var req struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+ }
+ if !decodeJSON(w, r, &req) {
+ return
+ }
+ auth, passwordHash, err := s.store.GetUserByEmail(strings.TrimSpace(req.Email))
+ if err != nil || !authn.VerifyPassword(passwordHash, req.Password) {
+ s.audit("anonymous", "anonymous", "api.auth_denied", "auth_session", "local", map[string]any{
+ "method": r.Method,
+ "path": r.URL.Path,
+ "mode": s.cfg.AuthMode,
+ "email": strings.TrimSpace(req.Email),
+ "error": "invalid local credentials",
+ "trace_id": observability.TraceID(r.Context()),
+ })
+ writeError(w, http.StatusUnauthorized, "email or password is incorrect")
+ return
+ }
+ if _, err := s.createAuthSession(w, r, auth, "local", auth.User.ID, "local"); err != nil {
+ writeError(w, http.StatusInternalServerError, "session persistence failed")
+ return
+ }
writeJSON(w, http.StatusOK, auth)
return
}
@@ -953,6 +981,82 @@ func (s *Server) authLogout(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out", "mode": s.cfg.AuthMode})
}
+func (s *Server) users(w http.ResponseWriter, r *http.Request) {
+ auth, err := s.requestAuth(r)
+ if err != nil {
+ writeError(w, http.StatusUnauthorized, "authentication required")
+ return
+ }
+ switch r.Method {
+ case http.MethodGet:
+ users := s.store.ListUsers(auth.Membership.OrgID)
+ memberships := s.store.ListMemberships(auth.Membership.OrgID)
+ projectMemberships := []domain.ProjectMembership{}
+ for _, user := range users {
+ projectMemberships = append(projectMemberships, s.store.ListProjectMembershipsForUser(user.ID)...)
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "users": users,
+ "memberships": memberships,
+ "project_memberships": projectMemberships,
+ })
+ case http.MethodPost:
+ var req struct {
+ Email string `json:"email"`
+ DisplayName string `json:"display_name"`
+ Role string `json:"role"`
+ Password string `json:"password"`
+ ExternalProvider string `json:"external_provider"`
+ ExternalSubject string `json:"external_subject"`
+ }
+ if !decodeJSON(w, r, &req) {
+ return
+ }
+ if req.Email == "" {
+ writeError(w, http.StatusBadRequest, "email is required")
+ return
+ }
+ created, err := s.store.UpsertUser(domain.User{
+ Email: req.Email,
+ DisplayName: req.DisplayName,
+ ExternalProvider: req.ExternalProvider,
+ ExternalSubject: req.ExternalSubject,
+ }, auth.Membership.OrgID, req.Role)
+ if err != nil {
+ if errors.Is(err, store.ErrInvalidID) {
+ writeError(w, http.StatusBadRequest, "org_id must be a UUID")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ passwordUpdated := strings.TrimSpace(req.Password) != ""
+ if passwordUpdated {
+ passwordHash, err := authn.HashPassword(req.Password)
+ if err != nil {
+ if errors.Is(err, authn.ErrPasswordTooShort) {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "password hashing failed")
+ return
+ }
+ if err := s.store.SetUserPassword(created.User.ID, passwordHash); err != nil {
+ if errors.Is(err, store.ErrInvalidID) || errors.Is(err, store.ErrNotFound) {
+ writeError(w, http.StatusBadRequest, "user_id is invalid")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "password update failed")
+ return
+ }
+ }
+ s.auditHuman(r, "api.user_upsert", "user", created.User.ID, map[string]any{"email": created.User.Email, "role": created.Membership.Role, "password_updated": passwordUpdated})
+ writeJSON(w, http.StatusCreated, created)
+ default:
+ methodNotAllowed(w)
+ }
+}
+
func (s *Server) organizations(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@@ -1003,6 +1107,10 @@ func (s *Server) projects(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
+ if !canAdminOrg(auth, auth.Membership.OrgID) {
+ s.denyResource(w, r, auth, "api.project_create", "organization", auth.Membership.OrgID, auth.Membership.OrgID)
+ return
+ }
project := s.store.CreateProject(domain.Project{OrgID: auth.Membership.OrgID, Name: req.Name, Slug: req.Slug, Description: req.Description})
s.auditHuman(r, "api.project_create", "project", project.ID, map[string]any{"slug": project.Slug})
writeJSON(w, http.StatusCreated, project)
@@ -1023,6 +1131,45 @@ func (s *Server) project(w http.ResponseWriter, r *http.Request, projectID strin
writeJSON(w, http.StatusOK, project)
}
+func (s *Server) projectMembers(w http.ResponseWriter, r *http.Request, projectID string) {
+ if _, ok := s.authorizeProject(w, r, projectID, "api.project_members_access"); !ok {
+ return
+ }
+ switch r.Method {
+ case http.MethodGet:
+ writeJSON(w, http.StatusOK, s.store.ListProjectMemberships(projectID))
+ case http.MethodPost:
+ var req struct {
+ UserID string `json:"user_id"`
+ Role string `json:"role"`
+ }
+ if !decodeJSON(w, r, &req) {
+ return
+ }
+ if req.UserID == "" {
+ writeError(w, http.StatusBadRequest, "user_id is required")
+ return
+ }
+ membership, err := s.store.UpsertProjectMembership(domain.ProjectMembership{
+ ProjectID: projectID,
+ UserID: req.UserID,
+ Role: req.Role,
+ })
+ if err != nil {
+ if errors.Is(err, store.ErrInvalidID) {
+ writeError(w, http.StatusBadRequest, "project_id and user_id must be UUIDs")
+ return
+ }
+ notFound(w, err)
+ return
+ }
+ s.auditHuman(r, "api.project_member_upsert", "project", projectID, map[string]any{"user_id": req.UserID, "role": membership.Role})
+ writeJSON(w, http.StatusCreated, membership)
+ default:
+ methodNotAllowed(w)
+ }
+}
+
func (s *Server) repositories(w http.ResponseWriter, r *http.Request, projectID string) {
if _, ok := s.authorizeProject(w, r, projectID, "api.repositories_access"); !ok {
return
@@ -2029,9 +2176,15 @@ func requiredPermission(method string, path string) string {
return ""
}
if method == http.MethodGet {
+ if strings.Contains(path, "/users") {
+ return "users:read"
+ }
if strings.Contains(path, "/organizations") {
return "organizations:read"
}
+ if strings.Contains(path, "/members") {
+ return "projects:read"
+ }
if strings.Contains(path, "/audit-logs") || strings.Contains(path, "/tool-calls") {
return "audit:read"
}
@@ -2046,9 +2199,15 @@ func requiredPermission(method string, path string) string {
}
return "projects:read"
}
+ if strings.Contains(path, "/users") {
+ return "users:write"
+ }
if strings.Contains(path, "/organizations") {
return "organizations:write"
}
+ if strings.Contains(path, "/members") {
+ return "projects:write"
+ }
if strings.Contains(path, "/executor-nodes") {
return "nodes:write"
}
@@ -2096,6 +2255,25 @@ func canAccessOrg(auth domain.AuthContext, orgID string) bool {
return orgID == "" || auth.Membership.OrgID == "" || auth.Membership.OrgID == orgID
}
+func canAdminOrg(auth domain.AuthContext, orgID string) bool {
+ return canAccessOrg(auth, orgID) && hasPermission(auth.Permissions, "*")
+}
+
+func canAccessProject(auth domain.AuthContext, project domain.Project) bool {
+ if canAdminOrg(auth, project.OrgID) {
+ return true
+ }
+ if !canAccessOrg(auth, project.OrgID) {
+ return false
+ }
+ for _, membership := range auth.ProjectMemberships {
+ if membership.ProjectID == project.ID {
+ return true
+ }
+ }
+ return false
+}
+
func (s *Server) denyResource(w http.ResponseWriter, r *http.Request, auth domain.AuthContext, action string, resourceType string, resourceID string, resourceOrgID string) {
actorID := auth.User.ID
if actorID == "" {
@@ -2121,7 +2299,7 @@ func (s *Server) authorizeProject(w http.ResponseWriter, r *http.Request, projec
writeError(w, http.StatusUnauthorized, "authentication required")
return domain.Project{}, false
}
- if !canAccessOrg(auth, project.OrgID) {
+ if !canAccessProject(auth, project) {
s.denyResource(w, r, auth, action, "project", project.ID, project.OrgID)
return domain.Project{}, false
}
@@ -2227,7 +2405,7 @@ func (s *Server) filterProjectsForAuth(r *http.Request, projects []domain.Projec
}
filtered := []domain.Project{}
for _, project := range projects {
- if canAccessOrg(auth, project.OrgID) {
+ if canAccessProject(auth, project) {
filtered = append(filtered, project)
}
}
@@ -2274,7 +2452,7 @@ func (s *Server) filterRunsForAuth(r *http.Request, runs []domain.Run) []domain.
continue
}
auth, err := s.requestAuth(r)
- if err != nil || !canAccessOrg(auth, project.OrgID) {
+ if err != nil || !canAccessProject(auth, project) {
continue
}
filtered = append(filtered, run)
@@ -2294,7 +2472,7 @@ func (s *Server) filterApprovalsForAuth(r *http.Request, approvals []domain.Appr
continue
}
auth, err := s.requestAuth(r)
- if err != nil || !canAccessOrg(auth, project.OrgID) {
+ if err != nil || !canAccessProject(auth, project) {
continue
}
filtered = append(filtered, approval)
@@ -2317,18 +2495,18 @@ func (s *Server) filterAuditLogsForAuth(r *http.Request, logs []domain.AuditLog)
}
func (s *Server) authContext(r *http.Request) (domain.AuthContext, error) {
- if !strings.EqualFold(s.cfg.AuthMode, "oidc") {
- return s.store.GetAuthContext(), nil
- }
- if s.oidc == nil {
- return domain.AuthContext{}, fmt.Errorf("OIDC verifier is not configured")
- }
if cookie, err := r.Cookie(authSessionCookieName); err == nil && strings.TrimSpace(cookie.Value) != "" {
auth, _, err := s.store.GetAuthSession(authn.TokenHash(cookie.Value), time.Now().UTC())
if err == nil {
return auth, nil
}
}
+ if !strings.EqualFold(s.cfg.AuthMode, "oidc") {
+ return domain.AuthContext{}, authn.ErrMissingBearer
+ }
+ if s.oidc == nil {
+ return domain.AuthContext{}, fmt.Errorf("OIDC verifier is not configured")
+ }
token, ok := authn.BearerToken(r.Header.Get("Authorization"))
if !ok {
return domain.AuthContext{}, authn.ErrMissingBearer
@@ -2374,28 +2552,44 @@ func (s *Server) authContextFromClaims(r *http.Request, claims authn.Claims) (do
}
func (s *Server) createOIDCSession(w http.ResponseWriter, r *http.Request, auth domain.AuthContext, claims authn.Claims, source string) (domain.AuthSession, error) {
- sessionToken, err := newOpaqueToken()
- if err != nil {
- return domain.AuthSession{}, err
- }
expiresAt := time.Now().UTC().Add(s.cfg.AuthSessionTTL)
if !claims.ExpiresAt.IsZero() && claims.ExpiresAt.Before(expiresAt) {
expiresAt = claims.ExpiresAt
}
- session, err := s.store.CreateAuthSessionWithExternalID(authn.TokenHash(sessionToken), auth, "oidc", claims.Subject, claims.SessionID, expiresAt)
- if err != nil {
- return domain.AuthSession{}, err
- }
- http.SetCookie(w, s.authSessionCookie(sessionToken, expiresAt))
- s.audit("human", auth.User.ID, "api.auth_session_create", "auth_session", session.ID, map[string]any{
+ session, err := s.createAuthSessionWithExternalID(w, r, auth, "oidc", claims.Subject, claims.SessionID, expiresAt, map[string]any{
"subject": claims.Subject,
"issuer": claims.Issuer,
"source": source,
- "expires_at": expiresAt.Format(time.RFC3339Nano),
- "session_hash_prefix": hashPrefix(session.TokenHash),
"external_session_id_present": claims.SessionID != "",
- "trace_id": observability.TraceID(r.Context()),
})
+ return session, err
+}
+
+func (s *Server) createAuthSession(w http.ResponseWriter, r *http.Request, auth domain.AuthContext, provider string, subject string, source string) (domain.AuthSession, error) {
+ return s.createAuthSessionWithExternalID(w, r, auth, provider, subject, "", time.Now().UTC().Add(s.cfg.AuthSessionTTL), map[string]any{
+ "subject": subject,
+ "source": source,
+ })
+}
+
+func (s *Server) createAuthSessionWithExternalID(w http.ResponseWriter, r *http.Request, auth domain.AuthContext, provider string, subject string, externalSessionID string, expiresAt time.Time, payload map[string]any) (domain.AuthSession, error) {
+ sessionToken, err := newOpaqueToken()
+ if err != nil {
+ return domain.AuthSession{}, err
+ }
+ session, err := s.store.CreateAuthSessionWithExternalID(authn.TokenHash(sessionToken), auth, provider, subject, externalSessionID, expiresAt)
+ if err != nil {
+ return domain.AuthSession{}, err
+ }
+ http.SetCookie(w, s.authSessionCookie(sessionToken, expiresAt))
+ if payload == nil {
+ payload = map[string]any{}
+ }
+ payload["provider"] = provider
+ payload["expires_at"] = expiresAt.Format(time.RFC3339Nano)
+ payload["session_hash_prefix"] = hashPrefix(session.TokenHash)
+ payload["trace_id"] = observability.TraceID(r.Context())
+ s.audit("human", auth.User.ID, "api.auth_session_create", "auth_session", session.ID, payload)
return session, nil
}
@@ -2445,6 +2639,14 @@ func authEndpointDescriptor(rawURL string) map[string]any {
}
}
+func localAdminEmail(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return "local-dev@multi-codex.invalid"
+ }
+ return value
+}
+
func readLogoutToken(r *http.Request) (string, error) {
contentType := r.Header.Get("Content-Type")
if strings.Contains(contentType, "application/json") {
diff --git a/internal/api/run_events_stream_test.go b/internal/api/run_events_stream_test.go
index 49b9f52..c48cdf6 100644
--- a/internal/api/run_events_stream_test.go
+++ b/internal/api/run_events_stream_test.go
@@ -43,8 +43,14 @@ func TestRunEventStreamSendsNewEventsAndAudits(t *testing.T) {
server := NewServer(config.Config{QueueEnabled: false}, st, slog.New(slog.NewTextHandler(io.Discard, nil)))
testServer := httptest.NewServer(server.Handler())
defer testServer.Close()
+ cookie := localSessionCookie(t, server.Handler())
- resp, err := testServer.Client().Get(testServer.URL + "/api/v1/runs/" + run.ID + "/events/stream")
+ req, err := http.NewRequest(http.MethodGet, testServer.URL+"/api/v1/runs/"+run.ID+"/events/stream", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.AddCookie(cookie)
+ resp, err := testServer.Client().Do(req)
if err != nil {
t.Fatal(err)
}
diff --git a/internal/api/skills_test.go b/internal/api/skills_test.go
index 68d1ac6..9b3cbd7 100644
--- a/internal/api/skills_test.go
+++ b/internal/api/skills_test.go
@@ -25,6 +25,7 @@ func TestSkillVersionHistoryAPI(t *testing.T) {
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/skills/"+latest.ID+"/versions", nil)
+ req.AddCookie(localSessionCookie(t, server.Handler()))
resp := httptest.NewRecorder()
server.Handler().ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
@@ -49,6 +50,7 @@ func TestSkillVersionHistoryAPI(t *testing.T) {
func createSkillVersion(t *testing.T, server *Server, body string) domain.Skill {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/skills", bytes.NewBufferString(body))
+ req.AddCookie(localSessionCookie(t, server.Handler()))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
server.Handler().ServeHTTP(resp, req)
diff --git a/internal/auth/password.go b/internal/auth/password.go
new file mode 100644
index 0000000..e871ff5
--- /dev/null
+++ b/internal/auth/password.go
@@ -0,0 +1,31 @@
+package auth
+
+import (
+ "errors"
+ "strings"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+const MinPasswordLength = 6
+
+var ErrPasswordTooShort = errors.New("password must be at least 6 characters")
+
+func HashPassword(password string) (string, error) {
+ password = strings.TrimSpace(password)
+ if len(password) < MinPasswordLength {
+ return "", ErrPasswordTooShort
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return "", err
+ }
+ return string(hash), nil
+}
+
+func VerifyPassword(hash string, password string) bool {
+ if strings.TrimSpace(hash) == "" || strings.TrimSpace(password) == "" {
+ return false
+ }
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index e154bdc..3e10d09 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -55,6 +55,8 @@ type Config struct {
AuthSessionTTL time.Duration
AuthCookieSecure bool
AuthLoginStateTTL time.Duration
+ LocalAdminEmail string
+ LocalAdminPassword string
OIDCIssuer string
OIDCAudience string
OIDCJWKSURL string
@@ -145,6 +147,8 @@ func FromEnv() Config {
AuthSessionTTL: envDuration("MULTICODEX_AUTH_SESSION_TTL", 12*time.Hour),
AuthCookieSecure: envBool("MULTICODEX_AUTH_COOKIE_SECURE", false),
AuthLoginStateTTL: envDuration("MULTICODEX_AUTH_LOGIN_STATE_TTL", 10*time.Minute),
+ LocalAdminEmail: env("MULTICODEX_LOCAL_ADMIN_EMAIL", "local-dev@multi-codex.invalid"),
+ LocalAdminPassword: env("MULTICODEX_LOCAL_ADMIN_PASSWORD", "admin123"),
OIDCIssuer: env("MULTICODEX_OIDC_ISSUER", ""),
OIDCAudience: env("MULTICODEX_OIDC_AUDIENCE", ""),
OIDCJWKSURL: env("MULTICODEX_OIDC_JWKS_URL", ""),
diff --git a/internal/db/migrations/000010_project_memberships.sql b/internal/db/migrations/000010_project_memberships.sql
new file mode 100644
index 0000000..30661c7
--- /dev/null
+++ b/internal/db/migrations/000010_project_memberships.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS project_memberships (
+ project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ role text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (project_id, user_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_project_memberships_user
+ ON project_memberships(user_id, project_id);
diff --git a/internal/db/migrations/000011_user_password_credentials.sql b/internal/db/migrations/000011_user_password_credentials.sql
new file mode 100644
index 0000000..1dac8c1
--- /dev/null
+++ b/internal/db/migrations/000011_user_password_credentials.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS user_password_credentials (
+ user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ password_hash text NOT NULL,
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_user_password_credentials_updated
+ ON user_password_credentials(updated_at);
diff --git a/internal/domain/types.go b/internal/domain/types.go
index 5c379e6..9d838d5 100644
--- a/internal/domain/types.go
+++ b/internal/domain/types.go
@@ -35,10 +35,12 @@ type Repository struct {
}
type User struct {
- ID string `json:"id"`
- Email string `json:"email"`
- DisplayName string `json:"display_name"`
- CreatedAt time.Time `json:"created_at"`
+ ID string `json:"id"`
+ Email string `json:"email"`
+ DisplayName string `json:"display_name"`
+ ExternalProvider string `json:"external_provider,omitempty"`
+ ExternalSubject string `json:"external_subject,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
}
type Membership struct {
@@ -48,10 +50,22 @@ type Membership struct {
CreatedAt time.Time `json:"created_at"`
}
+type ProjectMembership struct {
+ ProjectID string `json:"project_id"`
+ UserID string `json:"user_id"`
+ Role string `json:"role"`
+ ProjectName string `json:"project_name,omitempty"`
+ ProjectSlug string `json:"project_slug,omitempty"`
+ UserEmail string `json:"user_email,omitempty"`
+ UserName string `json:"user_name,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
type AuthContext struct {
- User User `json:"user"`
- Membership Membership `json:"membership"`
- Permissions []string `json:"permissions"`
+ User User `json:"user"`
+ Membership Membership `json:"membership"`
+ ProjectMemberships []ProjectMembership `json:"project_memberships"`
+ Permissions []string `json:"permissions"`
}
type Skill struct {
diff --git a/internal/store/factory.go b/internal/store/factory.go
index 286c474..f57ca0a 100644
--- a/internal/store/factory.go
+++ b/internal/store/factory.go
@@ -6,6 +6,8 @@ import (
"log/slog"
"time"
+ "github.com/Chiiz0/multi-codex/internal/config"
+
_ "github.com/jackc/pgx/v5/stdlib"
)
@@ -15,9 +17,14 @@ type RuntimeStore struct {
}
func Open(ctx context.Context, databaseURL string, log *slog.Logger) (RuntimeStore, error) {
+ return OpenWithConfig(ctx, config.Config{DatabaseURL: databaseURL}, log)
+}
+
+func OpenWithConfig(ctx context.Context, cfg config.Config, log *slog.Logger) (RuntimeStore, error) {
+ databaseURL := cfg.DatabaseURL
if databaseURL == "" {
log.Info("using in-memory store")
- return RuntimeStore{Store: NewMemoryStore(), Close: func() error { return nil }}, nil
+ return RuntimeStore{Store: NewMemoryStoreWithSeed(cfg.LocalAdminEmail, cfg.LocalAdminPassword), Close: func() error { return nil }}, nil
}
db, err := sql.Open("pgx", databaseURL)
@@ -36,7 +43,7 @@ func Open(ctx context.Context, databaseURL string, log *slog.Logger) (RuntimeSto
}
pg := NewPostgresStore(db, log, databaseURL)
- if err := pg.EnsureSeed(ctx); err != nil {
+ if err := pg.EnsureSeedWithCredentials(ctx, cfg.LocalAdminEmail, cfg.LocalAdminPassword); err != nil {
_ = db.Close()
return RuntimeStore{}, err
}
diff --git a/internal/store/memory.go b/internal/store/memory.go
index a7ace25..c2bde2e 100644
--- a/internal/store/memory.go
+++ b/internal/store/memory.go
@@ -3,9 +3,11 @@ package store
import (
"errors"
"sort"
+ "strings"
"sync"
"time"
+ authn "github.com/Chiiz0/multi-codex/internal/auth"
"github.com/Chiiz0/multi-codex/internal/domain"
)
@@ -14,68 +16,86 @@ var ErrNoCapacity = errors.New("no executor capacity available")
var ErrConflict = errors.New("resource already exists")
type MemoryStore struct {
- mu sync.RWMutex
- auth domain.AuthContext
- externalUsers map[string]domain.AuthContext
- revokedTokens map[string]domain.AuthTokenRevocation
- authSessions map[string]domain.AuthSession
- sessionAuth map[string]domain.AuthContext
- loginStates map[string]domain.AuthLoginState
- organizations map[string]domain.Organization
- projects map[string]domain.Project
- repositories map[string]domain.Repository
- skills map[string]domain.Skill
- skillVersions map[string][]domain.SkillVersion
- profiles map[string]domain.AgentProfile
- nodes map[string]domain.ExecutorNode
- tasks map[string]domain.Task
- runs map[string]domain.Run
- events map[string][]domain.RunEvent
- artifacts map[string]domain.Artifact
- scopeChecks map[string][]domain.ScopeCheckRecord
- approvals map[string]domain.Approval
- toolCalls []domain.ToolCall
- mcpSessions map[string]domain.MCPSession
- mcpEvents map[string][]domain.MCPSessionEvent
- auditLogs []domain.AuditLog
- nextEventID int64
+ mu sync.RWMutex
+ auth domain.AuthContext
+ externalUsers map[string]domain.AuthContext
+ revokedTokens map[string]domain.AuthTokenRevocation
+ authSessions map[string]domain.AuthSession
+ sessionAuth map[string]domain.AuthContext
+ loginStates map[string]domain.AuthLoginState
+ users map[string]domain.User
+ passwords map[string]string
+ memberships map[string]domain.Membership
+ projectMembers map[string]domain.ProjectMembership
+ organizations map[string]domain.Organization
+ projects map[string]domain.Project
+ repositories map[string]domain.Repository
+ skills map[string]domain.Skill
+ skillVersions map[string][]domain.SkillVersion
+ profiles map[string]domain.AgentProfile
+ nodes map[string]domain.ExecutorNode
+ tasks map[string]domain.Task
+ runs map[string]domain.Run
+ events map[string][]domain.RunEvent
+ artifacts map[string]domain.Artifact
+ scopeChecks map[string][]domain.ScopeCheckRecord
+ approvals map[string]domain.Approval
+ toolCalls []domain.ToolCall
+ mcpSessions map[string]domain.MCPSession
+ mcpEvents map[string][]domain.MCPSessionEvent
+ auditLogs []domain.AuditLog
+ nextEventID int64
}
func NewMemoryStore() *MemoryStore {
+ return NewMemoryStoreWithSeed("", "")
+}
+
+func NewMemoryStoreWithSeed(adminEmail string, adminPassword string) *MemoryStore {
s := &MemoryStore{
- projects: map[string]domain.Project{},
- externalUsers: map[string]domain.AuthContext{},
- revokedTokens: map[string]domain.AuthTokenRevocation{},
- authSessions: map[string]domain.AuthSession{},
- sessionAuth: map[string]domain.AuthContext{},
- loginStates: map[string]domain.AuthLoginState{},
- organizations: map[string]domain.Organization{},
- repositories: map[string]domain.Repository{},
- skills: map[string]domain.Skill{},
- skillVersions: map[string][]domain.SkillVersion{},
- profiles: map[string]domain.AgentProfile{},
- nodes: map[string]domain.ExecutorNode{},
- tasks: map[string]domain.Task{},
- runs: map[string]domain.Run{},
- events: map[string][]domain.RunEvent{},
- artifacts: map[string]domain.Artifact{},
- scopeChecks: map[string][]domain.ScopeCheckRecord{},
- approvals: map[string]domain.Approval{},
- toolCalls: []domain.ToolCall{},
- mcpSessions: map[string]domain.MCPSession{},
- mcpEvents: map[string][]domain.MCPSessionEvent{},
- auditLogs: []domain.AuditLog{},
- nextEventID: 1,
- }
- s.seed()
+ projects: map[string]domain.Project{},
+ externalUsers: map[string]domain.AuthContext{},
+ revokedTokens: map[string]domain.AuthTokenRevocation{},
+ authSessions: map[string]domain.AuthSession{},
+ sessionAuth: map[string]domain.AuthContext{},
+ loginStates: map[string]domain.AuthLoginState{},
+ users: map[string]domain.User{},
+ passwords: map[string]string{},
+ memberships: map[string]domain.Membership{},
+ projectMembers: map[string]domain.ProjectMembership{},
+ organizations: map[string]domain.Organization{},
+ repositories: map[string]domain.Repository{},
+ skills: map[string]domain.Skill{},
+ skillVersions: map[string][]domain.SkillVersion{},
+ profiles: map[string]domain.AgentProfile{},
+ nodes: map[string]domain.ExecutorNode{},
+ tasks: map[string]domain.Task{},
+ runs: map[string]domain.Run{},
+ events: map[string][]domain.RunEvent{},
+ artifacts: map[string]domain.Artifact{},
+ scopeChecks: map[string][]domain.ScopeCheckRecord{},
+ approvals: map[string]domain.Approval{},
+ toolCalls: []domain.ToolCall{},
+ mcpSessions: map[string]domain.MCPSession{},
+ mcpEvents: map[string][]domain.MCPSessionEvent{},
+ auditLogs: []domain.AuditLog{},
+ nextEventID: 1,
+ }
+ s.seed(adminEmail, adminPassword)
return s
}
-func (s *MemoryStore) seed() {
+func (s *MemoryStore) seed(adminEmail string, adminPassword string) {
now := time.Now().UTC()
+ if adminEmail == "" {
+ adminEmail = "local-dev@multi-codex.invalid"
+ }
+ if adminPassword == "" {
+ adminPassword = "admin123"
+ }
user := domain.User{
ID: "user_local_dev",
- Email: "local-dev@multi-codex.invalid",
+ Email: adminEmail,
DisplayName: "Local Developer",
CreatedAt: now,
}
@@ -86,9 +106,10 @@ func (s *MemoryStore) seed() {
CreatedAt: now,
}
s.auth = domain.AuthContext{
- User: user,
- Membership: membership,
- Permissions: []string{"*"},
+ User: user,
+ Membership: membership,
+ ProjectMemberships: []domain.ProjectMembership{},
+ Permissions: []string{"*"},
}
org := domain.Organization{
ID: membership.OrgID,
@@ -114,7 +135,24 @@ func (s *MemoryStore) seed() {
CreatedAt: now,
}
s.organizations[org.ID] = org
+ s.users[user.ID] = user
+ if passwordHash, err := authn.HashPassword(adminPassword); err == nil {
+ s.passwords[user.ID] = passwordHash
+ }
+ s.memberships[membershipKey(membership.OrgID, membership.UserID)] = membership
s.projects[project.ID] = project
+ projectMembership := domain.ProjectMembership{
+ ProjectID: project.ID,
+ UserID: user.ID,
+ Role: "owner",
+ ProjectName: project.Name,
+ ProjectSlug: project.Slug,
+ UserEmail: user.Email,
+ UserName: user.DisplayName,
+ CreatedAt: now,
+ }
+ s.projectMembers[projectMembershipKey(project.ID, user.ID)] = projectMembership
+ s.auth.ProjectMemberships = []domain.ProjectMembership{projectMembership}
s.repositories[repo.ID] = repo
for _, seed := range seedSkillVersions(now) {
s.skills[seed.Skill.ID] = seed.Skill
@@ -139,7 +177,10 @@ func (s *MemoryStore) GetAuthContext() domain.AuthContext {
s.mu.RLock()
defer s.mu.RUnlock()
- return s.auth
+ auth := s.auth
+ auth.ProjectMemberships = s.projectMembershipsForUserLocked(auth.User.ID)
+ auth.Permissions = permissionsForAuth(auth.Membership.Role, auth.ProjectMemberships)
+ return auth
}
func (s *MemoryStore) UpsertExternalUser(provider string, subject string, email string, displayName string, role string, orgID string) (domain.AuthContext, error) {
@@ -175,10 +216,12 @@ func (s *MemoryStore) UpsertExternalUser(provider string, subject string, email
}
auth := domain.AuthContext{
User: domain.User{
- ID: userID,
- Email: email,
- DisplayName: displayName,
- CreatedAt: createdAt,
+ ID: userID,
+ Email: email,
+ DisplayName: displayName,
+ ExternalProvider: provider,
+ ExternalSubject: subject,
+ CreatedAt: createdAt,
},
Membership: domain.Membership{
OrgID: orgID,
@@ -186,9 +229,12 @@ func (s *MemoryStore) UpsertExternalUser(provider string, subject string, email
Role: role,
CreatedAt: now,
},
- Permissions: PermissionsForRole(role),
}
+ auth.ProjectMemberships = s.projectMembershipsForUserLocked(userID)
+ auth.Permissions = permissionsForAuth(role, auth.ProjectMemberships)
s.externalUsers[key] = auth
+ s.users[userID] = auth.User
+ s.memberships[membershipKey(orgID, userID)] = auth.Membership
return auth, nil
}
@@ -428,6 +474,137 @@ func (s *MemoryStore) CleanupAuthLoginStates(cutoff time.Time, dryRun bool) (dom
return result, nil
}
+func (s *MemoryStore) ListUsers(orgID string) []domain.User {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ users := []domain.User{}
+ for _, membership := range s.memberships {
+ if orgID != "" && membership.OrgID != orgID {
+ continue
+ }
+ user, ok := s.users[membership.UserID]
+ if ok {
+ users = append(users, user)
+ }
+ }
+ sort.Slice(users, func(i, j int) bool { return users[i].Email < users[j].Email })
+ return users
+}
+
+func (s *MemoryStore) GetUserByEmail(email string) (domain.AuthContext, string, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ for _, user := range s.users {
+ if !strings.EqualFold(user.Email, strings.TrimSpace(email)) {
+ continue
+ }
+ var membership domain.Membership
+ for _, candidate := range s.memberships {
+ if candidate.UserID == user.ID {
+ membership = candidate
+ break
+ }
+ }
+ if membership.UserID == "" {
+ return domain.AuthContext{}, "", ErrNotFound
+ }
+ projectMemberships := s.projectMembershipsForUserLocked(user.ID)
+ return domain.AuthContext{
+ User: user,
+ Membership: membership,
+ ProjectMemberships: projectMemberships,
+ Permissions: permissionsForAuth(membership.Role, projectMemberships),
+ }, s.passwords[user.ID], nil
+ }
+ return domain.AuthContext{}, "", ErrNotFound
+}
+
+func (s *MemoryStore) SetUserPassword(userID string, passwordHash string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if userID == "" || passwordHash == "" {
+ return errors.New("user_id and password hash are required")
+ }
+ if _, ok := s.users[userID]; !ok {
+ return ErrNotFound
+ }
+ s.passwords[userID] = passwordHash
+ return nil
+}
+
+func (s *MemoryStore) ListMemberships(orgID string) []domain.Membership {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ memberships := []domain.Membership{}
+ for _, membership := range s.memberships {
+ if orgID == "" || membership.OrgID == orgID {
+ memberships = append(memberships, membership)
+ }
+ }
+ sort.Slice(memberships, func(i, j int) bool {
+ if memberships[i].Role == memberships[j].Role {
+ return memberships[i].UserID < memberships[j].UserID
+ }
+ return memberships[i].Role < memberships[j].Role
+ })
+ return memberships
+}
+
+func (s *MemoryStore) UpsertUser(user domain.User, orgID string, role string) (domain.AuthContext, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if user.Email == "" {
+ return domain.AuthContext{}, errors.New("email is required")
+ }
+ if user.DisplayName == "" {
+ user.DisplayName = user.Email
+ }
+ if orgID == "" {
+ orgID = s.auth.Membership.OrgID
+ }
+ if role == "" {
+ role = "viewer"
+ }
+ now := time.Now().UTC()
+ for _, existing := range s.users {
+ if existing.Email == user.Email {
+ user.ID = existing.ID
+ user.CreatedAt = existing.CreatedAt
+ if user.ExternalProvider == "" {
+ user.ExternalProvider = existing.ExternalProvider
+ }
+ if user.ExternalSubject == "" {
+ user.ExternalSubject = existing.ExternalSubject
+ }
+ break
+ }
+ }
+ if user.ID == "" {
+ user.ID = domain.NewID("user")
+ user.CreatedAt = now
+ }
+ if user.CreatedAt.IsZero() {
+ user.CreatedAt = now
+ }
+ s.users[user.ID] = user
+ membership := domain.Membership{OrgID: orgID, UserID: user.ID, Role: role, CreatedAt: now}
+ if existing, ok := s.memberships[membershipKey(orgID, user.ID)]; ok {
+ membership.CreatedAt = existing.CreatedAt
+ }
+ s.memberships[membershipKey(orgID, user.ID)] = membership
+ return domain.AuthContext{
+ User: user,
+ Membership: membership,
+ ProjectMemberships: s.projectMembershipsForUserLocked(user.ID),
+ Permissions: permissionsForAuth(role, s.projectMembershipsForUserLocked(user.ID)),
+ }, nil
+}
+
func (s *MemoryStore) ListOrganizations() []domain.Organization {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -497,6 +674,55 @@ func (s *MemoryStore) GetProject(id string) (domain.Project, error) {
return project, nil
}
+func (s *MemoryStore) ListProjectMemberships(projectID string) []domain.ProjectMembership {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ memberships := []domain.ProjectMembership{}
+ for _, membership := range s.projectMembers {
+ if projectID == "" || membership.ProjectID == projectID {
+ memberships = append(memberships, s.hydrateProjectMembershipLocked(membership))
+ }
+ }
+ sort.Slice(memberships, func(i, j int) bool { return memberships[i].UserEmail < memberships[j].UserEmail })
+ return memberships
+}
+
+func (s *MemoryStore) ListProjectMembershipsForUser(userID string) []domain.ProjectMembership {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ return s.projectMembershipsForUserLocked(userID)
+}
+
+func (s *MemoryStore) UpsertProjectMembership(membership domain.ProjectMembership) (domain.ProjectMembership, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if membership.ProjectID == "" || membership.UserID == "" {
+ return domain.ProjectMembership{}, errors.New("project_id and user_id are required")
+ }
+ if _, ok := s.projects[membership.ProjectID]; !ok {
+ return domain.ProjectMembership{}, ErrNotFound
+ }
+ if _, ok := s.users[membership.UserID]; !ok {
+ return domain.ProjectMembership{}, ErrNotFound
+ }
+ if membership.Role == "" {
+ membership.Role = "viewer"
+ }
+ key := projectMembershipKey(membership.ProjectID, membership.UserID)
+ if existing, ok := s.projectMembers[key]; ok {
+ membership.CreatedAt = existing.CreatedAt
+ }
+ if membership.CreatedAt.IsZero() {
+ membership.CreatedAt = time.Now().UTC()
+ }
+ membership = s.hydrateProjectMembershipLocked(membership)
+ s.projectMembers[key] = membership
+ return membership, nil
+}
+
func (s *MemoryStore) ListRepositories(projectID string) []domain.Repository {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -1420,3 +1646,34 @@ func (s *MemoryStore) appendEventLocked(runID string, level string, eventType st
s.events[runID] = append(s.events[runID], event)
return event
}
+
+func (s *MemoryStore) projectMembershipsForUserLocked(userID string) []domain.ProjectMembership {
+ memberships := []domain.ProjectMembership{}
+ for _, membership := range s.projectMembers {
+ if membership.UserID == userID {
+ memberships = append(memberships, s.hydrateProjectMembershipLocked(membership))
+ }
+ }
+ sort.Slice(memberships, func(i, j int) bool { return memberships[i].ProjectName < memberships[j].ProjectName })
+ return memberships
+}
+
+func (s *MemoryStore) hydrateProjectMembershipLocked(membership domain.ProjectMembership) domain.ProjectMembership {
+ if project, ok := s.projects[membership.ProjectID]; ok {
+ membership.ProjectName = project.Name
+ membership.ProjectSlug = project.Slug
+ }
+ if user, ok := s.users[membership.UserID]; ok {
+ membership.UserEmail = user.Email
+ membership.UserName = user.DisplayName
+ }
+ return membership
+}
+
+func membershipKey(orgID string, userID string) string {
+ return orgID + ":" + userID
+}
+
+func projectMembershipKey(projectID string, userID string) string {
+ return projectID + ":" + userID
+}
diff --git a/internal/store/postgres.go b/internal/store/postgres.go
index 11fa5c0..e9bbb6e 100644
--- a/internal/store/postgres.go
+++ b/internal/store/postgres.go
@@ -6,8 +6,10 @@ import (
"encoding/json"
"errors"
"log/slog"
+ "strings"
"time"
+ authn "github.com/Chiiz0/multi-codex/internal/auth"
"github.com/Chiiz0/multi-codex/internal/domain"
)
@@ -26,6 +28,17 @@ func NewPostgresStore(db *sql.DB, log *slog.Logger, databaseURL ...string) *Post
}
func (s *PostgresStore) EnsureSeed(ctx context.Context) error {
+ return s.EnsureSeedWithCredentials(ctx, "", "")
+}
+
+func (s *PostgresStore) EnsureSeedWithCredentials(ctx context.Context, adminEmail string, adminPassword string) error {
+ adminEmail = strings.TrimSpace(adminEmail)
+ if adminEmail == "" {
+ adminEmail = "local-dev@multi-codex.invalid"
+ }
+ if adminPassword == "" {
+ adminPassword = "admin123"
+ }
if _, err := s.db.ExecContext(ctx, `
INSERT INTO organizations (id, name, slug)
VALUES ($1::uuid, 'Default Organization', 'default')
@@ -34,8 +47,18 @@ ON CONFLICT (slug) DO NOTHING`, defaultOrgID); err != nil {
}
if _, err := s.db.ExecContext(ctx, `
INSERT INTO users (id, email, display_name)
-VALUES ($1::uuid, 'local-dev@multi-codex.invalid', 'Local Developer')
-ON CONFLICT (email) DO NOTHING`, defaultUserID); err != nil {
+VALUES ($1::uuid, $2, 'Local Developer')
+ON CONFLICT (email) DO NOTHING`, defaultUserID, adminEmail); err != nil {
+ return err
+ }
+ passwordHash, err := authn.HashPassword(adminPassword)
+ if err != nil {
+ return err
+ }
+ if _, err := s.db.ExecContext(ctx, `
+INSERT INTO user_password_credentials (user_id, password_hash)
+VALUES ($1::uuid, $2)
+ON CONFLICT (user_id) DO NOTHING`, defaultUserID, passwordHash); err != nil {
return err
}
if _, err := s.db.ExecContext(ctx, `
@@ -50,11 +73,16 @@ VALUES ($2::uuid, $1::uuid, 'Demo Engineering', 'demo-engineering', 'Seed projec
ON CONFLICT (org_id, slug) DO NOTHING`, defaultOrgID, defaultProjectID); err != nil {
return err
}
- _, err := s.db.ExecContext(ctx, `
+ if _, err := s.db.ExecContext(ctx, `
+INSERT INTO project_memberships (project_id, user_id, role)
+VALUES ($1::uuid, $2::uuid, 'owner')
+ON CONFLICT (project_id, user_id) DO UPDATE SET role = EXCLUDED.role`, defaultProjectID, defaultUserID); err != nil {
+ return err
+ }
+ if _, err := s.db.ExecContext(ctx, `
INSERT INTO repositories (id, project_id, name, provider, remote_url, default_branch)
VALUES ($1::uuid, $2::uuid, 'demo-service', 'local', 'file:///workspace/demo-service.git', 'main')
-ON CONFLICT DO NOTHING`, defaultRepoID, defaultProjectID)
- if err != nil {
+ON CONFLICT DO NOTHING`, defaultRepoID, defaultProjectID); err != nil {
return err
}
diff --git a/internal/store/postgres_resources.go b/internal/store/postgres_resources.go
index 9532948..443381c 100644
--- a/internal/store/postgres_resources.go
+++ b/internal/store/postgres_resources.go
@@ -1,6 +1,7 @@
package store
import (
+ "context"
"database/sql"
"encoding/json"
"errors"
@@ -29,10 +30,195 @@ LIMIT 1`, defaultUserID).Scan(
auth.User = domain.User{ID: defaultUserID, Email: "local-dev@multi-codex.invalid", DisplayName: "Local Developer", CreatedAt: now}
auth.Membership = domain.Membership{OrgID: defaultOrgID, UserID: defaultUserID, Role: "owner", CreatedAt: now}
}
- auth.Permissions = permissionsForRole(auth.Membership.Role)
+ auth.ProjectMemberships = s.listProjectMembershipsForUser(ctx, auth.User.ID)
+ auth.Permissions = permissionsForAuth(auth.Membership.Role, auth.ProjectMemberships)
return auth
}
+func (s *PostgresStore) ListUsers(orgID string) []domain.User {
+ if orgID != "" && !validUUIDText(orgID) {
+ return []domain.User{}
+ }
+ ctx, cancel := storeContext()
+ defer cancel()
+
+ rows, err := s.db.QueryContext(ctx, `
+SELECT DISTINCT u.id::text, u.email, u.display_name, COALESCE(u.external_provider, ''), COALESCE(u.external_subject, ''), u.created_at
+FROM users u
+JOIN memberships m ON m.user_id = u.id
+WHERE ($1 = '' OR m.org_id = $1::uuid)
+ORDER BY u.email ASC`, orgID)
+ if err != nil {
+ s.log.Error("list users failed", "error", err)
+ return nil
+ }
+ defer rows.Close()
+
+ users := []domain.User{}
+ for rows.Next() {
+ user, err := scanUser(rows)
+ if err != nil {
+ s.log.Error("scan user failed", "error", err)
+ return users
+ }
+ users = append(users, user)
+ }
+ return users
+}
+
+func (s *PostgresStore) ListMemberships(orgID string) []domain.Membership {
+ if orgID != "" && !validUUIDText(orgID) {
+ return []domain.Membership{}
+ }
+ ctx, cancel := storeContext()
+ defer cancel()
+
+ rows, err := s.db.QueryContext(ctx, `
+SELECT org_id::text, user_id::text, role, created_at
+FROM memberships
+WHERE ($1 = '' OR org_id = $1::uuid)
+ORDER BY created_at ASC`, orgID)
+ if err != nil {
+ s.log.Error("list memberships failed", "error", err)
+ return nil
+ }
+ defer rows.Close()
+
+ memberships := []domain.Membership{}
+ for rows.Next() {
+ var membership domain.Membership
+ if err := rows.Scan(&membership.OrgID, &membership.UserID, &membership.Role, &membership.CreatedAt); err != nil {
+ s.log.Error("scan membership failed", "error", err)
+ return memberships
+ }
+ memberships = append(memberships, membership)
+ }
+ return memberships
+}
+
+func (s *PostgresStore) GetUserByEmail(email string) (domain.AuthContext, string, error) {
+ ctx, cancel := storeContext()
+ defer cancel()
+
+ var auth domain.AuthContext
+ var passwordHash string
+ err := s.db.QueryRowContext(ctx, `
+SELECT u.id::text, u.email, u.display_name, COALESCE(u.external_provider, ''), COALESCE(u.external_subject, ''), u.created_at,
+ m.org_id::text, m.user_id::text, m.role, m.created_at,
+ COALESCE(c.password_hash, '')
+FROM users u
+JOIN memberships m ON m.user_id = u.id
+LEFT JOIN user_password_credentials c ON c.user_id = u.id
+WHERE lower(u.email) = lower($1)
+ORDER BY m.created_at ASC
+LIMIT 1`, email).Scan(
+ &auth.User.ID, &auth.User.Email, &auth.User.DisplayName, &auth.User.ExternalProvider, &auth.User.ExternalSubject, &auth.User.CreatedAt,
+ &auth.Membership.OrgID, &auth.Membership.UserID, &auth.Membership.Role, &auth.Membership.CreatedAt,
+ &passwordHash,
+ )
+ if errors.Is(err, sql.ErrNoRows) {
+ return domain.AuthContext{}, "", ErrNotFound
+ }
+ if err != nil {
+ return domain.AuthContext{}, "", err
+ }
+ auth.ProjectMemberships = s.listProjectMembershipsForUser(ctx, auth.User.ID)
+ auth.Permissions = permissionsForAuth(auth.Membership.Role, auth.ProjectMemberships)
+ return auth, passwordHash, nil
+}
+
+func (s *PostgresStore) SetUserPassword(userID string, passwordHash string) error {
+ if !validUUIDText(userID) {
+ return ErrInvalidID
+ }
+ ctx, cancel := storeContext()
+ defer cancel()
+
+ result, err := s.db.ExecContext(ctx, `
+INSERT INTO user_password_credentials (user_id, password_hash, updated_at)
+VALUES ($1::uuid, $2, now())
+ON CONFLICT (user_id) DO UPDATE
+SET password_hash = EXCLUDED.password_hash,
+ updated_at = now()`, userID, passwordHash)
+ if err != nil {
+ return err
+ }
+ if affected, _ := result.RowsAffected(); affected == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+func (s *PostgresStore) UpsertUser(user domain.User, orgID string, role string) (domain.AuthContext, error) {
+ ctx, cancel := storeContext()
+ defer cancel()
+
+ if user.Email == "" {
+ return domain.AuthContext{}, errors.New("email is required")
+ }
+ if user.DisplayName == "" {
+ user.DisplayName = user.Email
+ }
+ if user.ExternalProvider == "" {
+ user.ExternalProvider = "local"
+ }
+ if role == "" {
+ role = "viewer"
+ }
+ if orgID == "" {
+ orgID = defaultOrgID
+ }
+ if !validUUIDText(orgID) {
+ return domain.AuthContext{}, ErrInvalidID
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return domain.AuthContext{}, err
+ }
+ defer tx.Rollback()
+
+ var auth domain.AuthContext
+ if user.ExternalSubject != "" {
+ err = tx.QueryRowContext(ctx, `
+INSERT INTO users (email, display_name, external_provider, external_subject)
+VALUES ($1, $2, $3, $4)
+ON CONFLICT (external_provider, external_subject) WHERE external_subject IS NOT NULL
+DO UPDATE SET email = EXCLUDED.email,
+ display_name = EXCLUDED.display_name
+RETURNING id::text, email, display_name, COALESCE(external_provider, ''), COALESCE(external_subject, ''), created_at`,
+ user.Email, user.DisplayName, user.ExternalProvider, user.ExternalSubject,
+ ).Scan(&auth.User.ID, &auth.User.Email, &auth.User.DisplayName, &auth.User.ExternalProvider, &auth.User.ExternalSubject, &auth.User.CreatedAt)
+ } else {
+ err = tx.QueryRowContext(ctx, `
+INSERT INTO users (email, display_name, external_provider)
+VALUES ($1, $2, $3)
+ON CONFLICT (email) DO UPDATE SET display_name = EXCLUDED.display_name
+RETURNING id::text, email, display_name, COALESCE(external_provider, ''), COALESCE(external_subject, ''), created_at`,
+ user.Email, user.DisplayName, user.ExternalProvider,
+ ).Scan(&auth.User.ID, &auth.User.Email, &auth.User.DisplayName, &auth.User.ExternalProvider, &auth.User.ExternalSubject, &auth.User.CreatedAt)
+ }
+ if err != nil {
+ return domain.AuthContext{}, err
+ }
+
+ if err := tx.QueryRowContext(ctx, `
+INSERT INTO memberships (org_id, user_id, role)
+VALUES ($1::uuid, $2::uuid, $3)
+ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role
+RETURNING org_id::text, user_id::text, role, created_at`,
+ orgID, auth.User.ID, role,
+ ).Scan(&auth.Membership.OrgID, &auth.Membership.UserID, &auth.Membership.Role, &auth.Membership.CreatedAt); err != nil {
+ return domain.AuthContext{}, err
+ }
+ if err := tx.Commit(); err != nil {
+ return domain.AuthContext{}, err
+ }
+ auth.ProjectMemberships = s.listProjectMembershipsForUser(ctx, auth.User.ID)
+ auth.Permissions = permissionsForAuth(auth.Membership.Role, auth.ProjectMemberships)
+ return auth, nil
+}
+
func (s *PostgresStore) ListOrganizations() []domain.Organization {
ctx, cancel := storeContext()
defer cancel()
@@ -112,9 +298,9 @@ VALUES ($1, $2, $3, $4)
ON CONFLICT (external_provider, external_subject) WHERE external_subject IS NOT NULL
DO UPDATE SET email = EXCLUDED.email,
display_name = EXCLUDED.display_name
-RETURNING id::text, email, display_name, created_at`,
+RETURNING id::text, email, display_name, COALESCE(external_provider, ''), COALESCE(external_subject, ''), created_at`,
email, displayName, provider, subject,
- ).Scan(&auth.User.ID, &auth.User.Email, &auth.User.DisplayName, &auth.User.CreatedAt); err != nil {
+ ).Scan(&auth.User.ID, &auth.User.Email, &auth.User.DisplayName, &auth.User.ExternalProvider, &auth.User.ExternalSubject, &auth.User.CreatedAt); err != nil {
return domain.AuthContext{}, err
}
@@ -130,10 +316,112 @@ RETURNING org_id::text, user_id::text, role, created_at`,
if err := tx.Commit(); err != nil {
return domain.AuthContext{}, err
}
- auth.Permissions = PermissionsForRole(auth.Membership.Role)
+ auth.ProjectMemberships = s.listProjectMembershipsForUser(ctx, auth.User.ID)
+ auth.Permissions = permissionsForAuth(auth.Membership.Role, auth.ProjectMemberships)
return auth, nil
}
+func (s *PostgresStore) ListProjectMemberships(projectID string) []domain.ProjectMembership {
+ if projectID != "" && !validUUIDText(projectID) {
+ return []domain.ProjectMembership{}
+ }
+ ctx, cancel := storeContext()
+ defer cancel()
+
+ rows, err := s.db.QueryContext(ctx, `
+SELECT pm.project_id::text, pm.user_id::text, pm.role, p.name, p.slug, u.email, u.display_name, pm.created_at
+FROM project_memberships pm
+JOIN projects p ON p.id = pm.project_id
+JOIN users u ON u.id = pm.user_id
+WHERE ($1 = '' OR pm.project_id = $1::uuid)
+ORDER BY p.name ASC, u.email ASC`, projectID)
+ if err != nil {
+ s.log.Error("list project memberships failed", "error", err)
+ return nil
+ }
+ defer rows.Close()
+
+ memberships := []domain.ProjectMembership{}
+ for rows.Next() {
+ membership, err := scanProjectMembership(rows)
+ if err != nil {
+ s.log.Error("scan project membership failed", "error", err)
+ return memberships
+ }
+ memberships = append(memberships, membership)
+ }
+ return memberships
+}
+
+func (s *PostgresStore) ListProjectMembershipsForUser(userID string) []domain.ProjectMembership {
+ if userID != "" && !validUUIDText(userID) {
+ return []domain.ProjectMembership{}
+ }
+ ctx, cancel := storeContext()
+ defer cancel()
+ return s.listProjectMembershipsForUser(ctx, userID)
+}
+
+func (s *PostgresStore) UpsertProjectMembership(membership domain.ProjectMembership) (domain.ProjectMembership, error) {
+ if !validUUIDText(membership.ProjectID) || !validUUIDText(membership.UserID) {
+ return domain.ProjectMembership{}, ErrInvalidID
+ }
+ if membership.Role == "" {
+ membership.Role = "viewer"
+ }
+ ctx, cancel := storeContext()
+ defer cancel()
+
+ row := s.db.QueryRowContext(ctx, `
+WITH upserted AS (
+ INSERT INTO project_memberships (project_id, user_id, role)
+ VALUES ($1::uuid, $2::uuid, $3)
+ ON CONFLICT (project_id, user_id) DO UPDATE SET role = EXCLUDED.role
+ RETURNING project_id, user_id, role, created_at
+)
+SELECT upserted.project_id::text, upserted.user_id::text, upserted.role,
+ p.name, p.slug, u.email, u.display_name, upserted.created_at
+FROM upserted
+JOIN projects p ON p.id = upserted.project_id
+JOIN users u ON u.id = upserted.user_id`,
+ membership.ProjectID, membership.UserID, membership.Role,
+ )
+ membership, err := scanProjectMembership(row)
+ if errors.Is(err, sql.ErrNoRows) {
+ return domain.ProjectMembership{}, ErrNotFound
+ }
+ return membership, err
+}
+
+func (s *PostgresStore) listProjectMembershipsForUser(ctx context.Context, userID string) []domain.ProjectMembership {
+ if userID == "" || !validUUIDText(userID) {
+ return []domain.ProjectMembership{}
+ }
+ rows, err := s.db.QueryContext(ctx, `
+SELECT pm.project_id::text, pm.user_id::text, pm.role, p.name, p.slug, u.email, u.display_name, pm.created_at
+FROM project_memberships pm
+JOIN projects p ON p.id = pm.project_id
+JOIN users u ON u.id = pm.user_id
+WHERE pm.user_id = $1::uuid
+ORDER BY p.name ASC`, userID)
+ if err != nil {
+ s.log.Error("list user project memberships failed", "error", err)
+ return nil
+ }
+ defer rows.Close()
+
+ memberships := []domain.ProjectMembership{}
+ for rows.Next() {
+ membership, err := scanProjectMembership(rows)
+ if err != nil {
+ s.log.Error("scan user project membership failed", "error", err)
+ return memberships
+ }
+ memberships = append(memberships, membership)
+ }
+ return memberships
+}
+
func (s *PostgresStore) RevokeAuthToken(revocation domain.AuthTokenRevocation) (domain.AuthTokenRevocation, error) {
ctx, cancel := storeContext()
defer cancel()
@@ -261,7 +549,8 @@ LIMIT 1`, tokenHash, now.UTC()).Scan(
return domain.AuthContext{}, domain.AuthSession{}, err
}
session.LastSeenAt = now.UTC()
- auth.Permissions = PermissionsForRole(auth.Membership.Role)
+ auth.ProjectMemberships = s.listProjectMembershipsForUser(ctx, auth.User.ID)
+ auth.Permissions = permissionsForAuth(auth.Membership.Role, auth.ProjectMemberships)
return auth, session, nil
}
@@ -966,6 +1255,33 @@ func scanAgentProfile(row rowScanner) (domain.AgentProfile, error) {
return profile, nil
}
+func scanUser(row rowScanner) (domain.User, error) {
+ var user domain.User
+ err := row.Scan(&user.ID, &user.Email, &user.DisplayName, &user.ExternalProvider, &user.ExternalSubject, &user.CreatedAt)
+ if err != nil {
+ return domain.User{}, err
+ }
+ return user, nil
+}
+
+func scanProjectMembership(row rowScanner) (domain.ProjectMembership, error) {
+ var membership domain.ProjectMembership
+ err := row.Scan(
+ &membership.ProjectID,
+ &membership.UserID,
+ &membership.Role,
+ &membership.ProjectName,
+ &membership.ProjectSlug,
+ &membership.UserEmail,
+ &membership.UserName,
+ &membership.CreatedAt,
+ )
+ if err != nil {
+ return domain.ProjectMembership{}, err
+ }
+ return membership, nil
+}
+
func scanExecutorNode(row rowScanner) (domain.ExecutorNode, error) {
var node domain.ExecutorNode
var labelsBytes []byte
@@ -1073,6 +1389,23 @@ func permissionsForRole(role string) []string {
return PermissionsForRole(role)
}
+func permissionsForAuth(orgRole string, projectMemberships []domain.ProjectMembership) []string {
+ values := map[string]struct{}{}
+ for _, permission := range PermissionsForRole(orgRole) {
+ values[permission] = struct{}{}
+ }
+ for _, membership := range projectMemberships {
+ for _, permission := range PermissionsForProjectRole(membership.Role) {
+ values[permission] = struct{}{}
+ }
+ }
+ permissions := make([]string, 0, len(values))
+ for permission := range values {
+ permissions = append(permissions, permission)
+ }
+ return permissions
+}
+
func PermissionsForRole(role string) []string {
switch role {
case "owner", "admin":
@@ -1089,3 +1422,20 @@ func PermissionsForRole(role string) []string {
return []string{"organizations:read", "projects:read", "tasks:read", "runs:read"}
}
}
+
+func PermissionsForProjectRole(role string) []string {
+ switch role {
+ case "owner", "project_admin":
+ return []string{"projects:read", "projects:write", "repositories:write", "tasks:write", "runs:read", "runs:write", "approvals:write", "nodes:read", "skills:write", "audit:read"}
+ case "maintainer":
+ return []string{"projects:read", "repositories:write", "tasks:write", "runs:read", "runs:write", "approvals:write", "nodes:read", "audit:read"}
+ case "developer":
+ return []string{"projects:read", "tasks:write", "runs:read", "runs:write", "nodes:read"}
+ case "reviewer":
+ return []string{"projects:read", "tasks:read", "runs:read", "approvals:write", "nodes:read", "audit:read"}
+ case "auditor":
+ return []string{"projects:read", "tasks:read", "runs:read", "nodes:read", "audit:read"}
+ default:
+ return []string{"projects:read", "tasks:read", "runs:read"}
+ }
+}
diff --git a/internal/store/store.go b/internal/store/store.go
index fad3488..a8de63e 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -23,11 +23,19 @@ type Store interface {
CreateAuthLoginState(state domain.AuthLoginState) (domain.AuthLoginState, error)
ConsumeAuthLoginState(stateHash string, now time.Time) (domain.AuthLoginState, error)
CleanupAuthLoginStates(cutoff time.Time, dryRun bool) (domain.AuthLoginStateRetentionResult, error)
+ GetUserByEmail(email string) (domain.AuthContext, string, error)
+ SetUserPassword(userID string, passwordHash string) error
+ ListUsers(orgID string) []domain.User
+ ListMemberships(orgID string) []domain.Membership
+ UpsertUser(user domain.User, orgID string, role string) (domain.AuthContext, error)
ListOrganizations() []domain.Organization
CreateOrganization(org domain.Organization) (domain.Organization, error)
ListProjects() []domain.Project
CreateProject(project domain.Project) domain.Project
GetProject(id string) (domain.Project, error)
+ ListProjectMemberships(projectID string) []domain.ProjectMembership
+ ListProjectMembershipsForUser(userID string) []domain.ProjectMembership
+ UpsertProjectMembership(membership domain.ProjectMembership) (domain.ProjectMembership, error)
ListRepositories(projectID string) []domain.Repository
CreateRepository(repo domain.Repository) domain.Repository
GetRepository(id string) (domain.Repository, error)