From 080efa70c5b23b1465b0bcd9ec99ed8e2012b7e0 Mon Sep 17 00:00:00 2001 From: ChickenPige0n <2336983354@qq.com> Date: Tue, 14 Jul 2026 21:25:18 +0800 Subject: [PATCH] feat(admin): gate project surfaces behind permission grants for members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org admin SPA was org-admin only: every project route used requireOrgRole, so a plain MEMBER could not reach the projects they held a project grant on, and an org OWNER/ADMIN could mutate any project without holding the project's `manage` grant. That contradicts ADR-0004 (spec `Permission.lean`): org role is not a project authorization root, and the only out-of-role override is platform-admin force-release (`RequiresAdmin`), not org admin. Add `requireProjectPermission` (guards.ts): resolve any org member, bind the project to their org, then check the PermissionGrant authorizer. `allowOrgAdminOversight=true` lets OWNER/ADMIN through for *read* oversight only; mutations pinned to `collaborator.manage` (grant/revoke team-access) pass `allowOrgAdminOversight=false`, so an org admin still needs the project MANAGE grant to mutate access. The project detail GET now also returns `actorIsOrgAdmin` and `actorCanManageProject` so the SPA can render mutation controls only for entitled actors. Add a member-facing project surface: - `GET /api/org/:orgSlug/my-projects` + `listMyProjects` resolve the actor's principals and return the projects with a READ+ grant. - The SPA routes members (non-admin) to the projects page instead of the admin overview, renders a member project shell on project routes, shows a `我的项目` list for members and the full folder explorer for admins. - The project detail page gates rename/archive/bind/sessions behind org admin and the grant/revoke UI behind `actorCanManageProject`. - The denied panel now points members at their authorized projects. Update admin-members-teams integration test: seed the owner with a MANAGE grant on the test project so the org-owner flow still passes the new project-level gate on team-access grant/revoke. --- hub/admin-web/src/lib/api.ts | 4 + hub/admin-web/src/routes/+layout.svelte | 108 +++++++++--- .../admin/org/[slug]/projects/+page.svelte | 156 ++++++++++++------ .../[slug]/projects/[projectId]/+page.svelte | 121 ++++++++------ hub/src/admin/auth/guards.ts | 54 ++++++ hub/src/admin/routes/accessRoutes.ts | 26 ++- hub/src/admin/routes/explorerRoutes.ts | 41 ++++- hub/src/org/explorer.ts | 58 +++++++ .../integration/admin-members-teams.test.ts | 12 ++ 9 files changed, 445 insertions(+), 135 deletions(-) diff --git a/hub/admin-web/src/lib/api.ts b/hub/admin-web/src/lib/api.ts index ea12366..5c35cbc 100644 --- a/hub/admin-web/src/lib/api.ts +++ b/hub/admin-web/src/lib/api.ts @@ -122,6 +122,8 @@ export interface ProjectDetail { archivedAt: string | null; createdBy: { id: string; displayName: string; feishuOpenId: string } | null; binding: { chatId: string; createdAt: string } | null; + actorIsOrgAdmin?: boolean; + actorCanManageProject?: boolean; } export interface TeamAccessEntry { @@ -259,6 +261,8 @@ export const api = { post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`), explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise, + myProjects: (slug: string) => + get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>, createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) => post(`${orgBase(slug)}/folders`, body) as Promise<{ id: string; diff --git a/hub/admin-web/src/routes/+layout.svelte b/hub/admin-web/src/routes/+layout.svelte index e97023f..b58118e 100644 --- a/hub/admin-web/src/routes/+layout.svelte +++ b/hub/admin-web/src/routes/+layout.svelte @@ -41,6 +41,11 @@ return null; } + function isOnProjectRoute(): boolean { + const parts = page.url.pathname.split('/').filter(Boolean); + return parts[0] === 'admin' && parts[1] === 'org' && parts[3] === 'projects'; + } + function memberships(): OrgMembership[] { return $session.me?.organizations ?? []; } @@ -104,22 +109,50 @@ $effect(() => { if ($session.loading || !$session.me) return; - const admins = adminOrgs(); - if (admins.length === 0) return; const slug = orgSlugFromPath(); const matched = slug ? memberships().find((o) => o.slug === slug) : null; + + // Org admins: route to their first admin org if none matched as admin. + const admins = adminOrgs(); if (matched && isAdmin(matched)) { redirecting = false; return; } - - if (!matched) { + if (!matched && admins.length > 0) { const target = `/admin/org/${admins[0].slug}`; if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) { redirecting = true; void goto(target, { replaceState: true }); } + return; + } + + // Members (non-admin): project pages are open to project MANAGE holders; + // the org overview and other admin-only surfaces are not for them. + if (matched && !isAdmin(matched)) { + redirecting = false; + const parts = page.url.pathname.split('/').filter(Boolean); + const onOverview = parts.length === 3; // /admin/org/:slug + if (onOverview) { + const target = `/admin/org/${matched.slug}/projects`; + if (page.url.pathname !== target) { + redirecting = true; + void goto(target, { replaceState: true }); + } + } + return; + } + + // No matched org and no admin orgs: route a member to their first org's + // projects page so they can reach project MANAGE surfaces. + if (!matched && memberships().length > 0) { + const home = memberships()[0]; + const target = `/admin/org/${home.slug}/projects`; + if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) { + redirecting = true; + void goto(target, { replaceState: true }); + } } }); @@ -271,6 +304,45 @@ +{:else if currentOrg() && !isAdmin(currentOrg()!) && isOnProjectRoute()} + {@const org = currentOrg()!} + {@const me = $session.me!} +
+
+
+ + + +
+
+ {org.name} + / + 项目 +
+
+
+ /{org.slug} + +
+
+
+
+ {@render children()} +
+
+
+
{:else if currentOrg() && !isAdmin(currentOrg()!)} {@const denied = currentOrg()!}
@@ -278,37 +350,27 @@

无权访问管理后台

组织 {denied.name}(/{denied.slug})中你的角色是 - {orgRoleLabel(denied.role)}, 需要所有者管理员。 + {orgRoleLabel(denied.role)}。普通成员仅可访问自己有授权的项目。

+ 查看我的项目 {#if memberships().length > 1} -

切换到其他组织

+

切换到其他组织

{/if} - +
{:else if memberships().length > 0} {@const denied = pickHomeOrg()!}
- {#if adminOrgs().length === 0} -

无权访问管理后台

-

- 你加入了 {denied.name},角色是 - {orgRoleLabel(denied.role)}。仅所有者与管理员可进入后台。 -

- {:else} -

正在跳转…

-

- 即将进入 {adminOrgs()[0].name} - (/{adminOrgs()[0].slug})。 -

- 立即进入 - {/if} +

正在跳转…

+

+ 即将进入 {denied.name}(/{denied.slug})的项目。 +

+ 立即进入
diff --git a/hub/admin-web/src/routes/admin/org/[slug]/projects/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/projects/+page.svelte index a8d6d62..d9829cb 100644 --- a/hub/admin-web/src/routes/admin/org/[slug]/projects/+page.svelte +++ b/hub/admin-web/src/routes/admin/org/[slug]/projects/+page.svelte @@ -1,6 +1,7 @@ - - {#snippet actions()} - - - {/snippet} - - {#if loading} {:else if error} -{:else if data} +{:else if isAdmin && data} + + {#snippet actions()} + + + {/snippet} + +
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0} @@ -112,48 +125,81 @@ {/if}
+ + + 名称 + { + if (e.key === 'Enter') createFolder(); + }} + /> + {#if data && data.folders.length > 0} +

父文件夹(可选)

+
+ +
+ {/if} +
+ + +
+
+ + + 项目名 + { + if (e.key === 'Enter') createProject(); + }} + /> + {#if data && data.folders.length > 0} +

文件夹(可选)

+
+ +
+ {/if} +
+ + +
+
+{:else if myProjects !== null} + + {#snippet actions()} + 仅显示已授权项目 + {/snippet} + + +
+ {#if myProjects.length === 0} + + {:else} + + + + + + + + + + {#each myProjects as p} + (window.location.href = `/admin/org/${slug}/projects/${p.id}`)}> + + + + + {/each} + +
项目飞书群创建时间
{p.name}{p.binding ? `群 ${p.binding.chatId}` : '—'}{fmtDate(p.createdAt)}
+ {/if} +
+{:else} + {/if} - - - 名称 - { - if (e.key === 'Enter') createFolder(); - }} - /> - {#if data && data.folders.length > 0} -

父文件夹(可选)

-
- -
- {/if} -
- - -
-
- - - 项目名 - { - if (e.key === 'Enter') createProject(); - }} - /> - {#if data && data.folders.length > 0} -

文件夹(可选)

-
- -
- {/if} -
- - -
-
diff --git a/hub/admin-web/src/routes/admin/org/[slug]/projects/[projectId]/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/projects/[projectId]/+page.svelte index 870d75e..59b27eb 100644 --- a/hub/admin-web/src/routes/admin/org/[slug]/projects/[projectId]/+page.svelte +++ b/hub/admin-web/src/routes/admin/org/[slug]/projects/[projectId]/+page.svelte @@ -34,23 +34,34 @@ let grantRole = $state('EDIT'); let moveFolder = $state(''); + const actorIsOrgAdmin = $derived(proj?.actorIsOrgAdmin ?? false); + const actorCanManage = $derived(proj?.actorCanManageProject ?? false); + async function load() { loading = true; error = null; try { - const [p, a, s, t, e] = await Promise.all([ - api.project(slug, projectId), - api.teamAccess(slug, projectId), - api.sessions(slug, projectId), - api.teams(slug), - api.explorer(slug), - ]); + // Project detail + team-access list are gated to project read/oversight. + const [p, a] = await Promise.all([api.project(slug, projectId), api.teamAccess(slug, projectId)]); proj = p; access = a.access; - sessions = s.sessions; - teams = t.teams; - explorer = e; - moveFolder = p.folderId ?? ''; + // Org-admin-only oversight sub-resources: sessions/teams/explorer are + // not available to member-level project MANAGE holders. + if (p.actorIsOrgAdmin) { + const [s, t, e] = await Promise.all([ + api.sessions(slug, projectId), + api.teams(slug), + api.explorer(slug), + ]); + sessions = s.sessions; + teams = t.teams; + explorer = e; + moveFolder = p.folderId ?? ''; + } else { + sessions = []; + teams = []; + explorer = null; + } } catch (err) { error = err instanceof Error ? err.message : String(err); } finally { @@ -157,11 +168,13 @@ {@const detail = proj} {#snippet actions()} - - {#if detail.binding} - + {#if actorIsOrgAdmin} + + {#if detail.binding} + + {/if} + {/if} - {/snippet} @@ -214,11 +227,15 @@

团队授权

通过团队授权项目访问。一项目可授多团队,一团队可访问多项目。

-
- - - -
+ {#if actorCanManage} +
+ + + +
+ {:else} +

需要项目 MANAGE 授权才能增删团队访问。

+ {/if} {#if access.length === 0} @@ -239,7 +256,11 @@ /{g.teamSlug} {permissionRoleLabel(g.role)} - + {#if actorCanManage} + + {:else} + + {/if} {/each} @@ -248,33 +269,39 @@ {/if} -
-
-

智能体会话

-
- {#if sessions.length === 0} - - {:else} - - - - - - - - - - - {#each sessions as s} + {#if actorIsOrgAdmin} +
+
+

智能体会话

+
+ {#if sessions.length === 0} + + {:else} +
供应方 / 角色模型运行次数更新
+ - - - - + + + + - {/each} - -
{s.provider} / {s.roleId}{s.model}{s.runCount}{fmtDate(s.updatedAt)}供应方 / 角色模型运行次数更新
- {/if} + + + {#each sessions as s} + + {s.provider} / {s.roleId} + {s.model} + {s.runCount} + {fmtDate(s.updatedAt)} + + {/each} + + + {/if} +
+ {/if} +{:else} +
+

项目数据不可用

{/if} diff --git a/hub/src/admin/auth/guards.ts b/hub/src/admin/auth/guards.ts index 3917561..b79b1ca 100644 --- a/hub/src/admin/auth/guards.ts +++ b/hub/src/admin/auth/guards.ts @@ -10,8 +10,10 @@ import { verifySession, type SessionPayload, } from "./session.js"; +import { createPermissionAuthorizer, type AuthorizationAction } from "../../permissions/authorizer.js"; export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"]; +const ANY_ORG_ROLE: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN", "MEMBER"]; export interface AuthContext { readonly session: SessionPayload; @@ -175,3 +177,55 @@ export async function sendError( ): Promise { await reply.status(statusCode).send({ error: { code, message } }); } + +export interface ProjectAuthContext extends OrgAuthContext { + readonly projectId: string; +} + +/** + * Resolve an org member (any role) viewing/mutating a project in their org, then + * enforce project-level permission via the PermissionGrant authorizer. + * + * `allowOrgAdminOversight=true` lets org OWNER/ADMIN through without a project + * grant — reserved for *read* oversight (listing/viewing). Mutations that the + * spec pins to project `manage` (e.g. `collaborator.manage`) must pass + * `allowOrgAdminOversight=false`: org role alone is not a project authorization + * root (spec `Permission.lean` / ADR-0004; the only out-of-role override is + * platform-admin force-release `RequiresAdmin`, not org admin). + */ +export async function requireProjectPermission( + request: FastifyRequest, + reply: FastifyReply, + deps: GuardDeps, + options: { + readonly orgSlug: string; + readonly projectId: string; + readonly action: AuthorizationAction; + readonly allowOrgAdminOversight: boolean; + }, +): Promise { + const auth = await requireOrgRole(request, reply, deps, { + orgSlug: options.orgSlug, + roles: ANY_ORG_ROLE, + }); + if (auth === null) return null; + await requireOrgProject(deps, auth.organization.id, options.projectId); + if (options.allowOrgAdminOversight && ORG_ADMIN_ROLES.includes(auth.membershipRole)) { + return { ...auth, projectId: options.projectId }; + } + const decision = await createPermissionAuthorizer(deps.prisma).can({ + actor: { feishuOpenId: auth.feishuOpenId }, + action: options.action, + resource: { type: "PROJECT", id: options.projectId }, + }); + if (!decision.allowed) { + await sendError( + reply, + 403, + "forbidden", + `project ${options.projectId} requires ${decision.requiredRole} (${options.action}): ${decision.reason}`, + ); + return null; + } + return { ...auth, projectId: options.projectId }; +} diff --git a/hub/src/admin/routes/accessRoutes.ts b/hub/src/admin/routes/accessRoutes.ts index c1d0b63..0a5fb69 100644 --- a/hub/src/admin/routes/accessRoutes.ts +++ b/hub/src/admin/routes/accessRoutes.ts @@ -5,7 +5,7 @@ import { listProjectTeamAccess, revokeTeamProjectAccess, } from "../../permissions/projectTeamAccess.js"; -import { requireOrgProject, requireOrgRole, type GuardDeps } from "../auth/guards.js"; +import { requireProjectPermission, type GuardDeps } from "../auth/guards.js"; import { handleRouteError } from "../errors.js"; const ROLES: readonly PermissionRole[] = ["READ", "EDIT", "MANAGE"]; @@ -19,9 +19,13 @@ export async function registerAccessRoutes( app.get("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => { try { const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; - const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + const auth = await requireProjectPermission(request, reply, guardDeps, { + orgSlug, + projectId, + action: "project.read", + allowOrgAdminOversight: true, + }); if (auth === null) return; - await requireOrgProject(guardDeps, auth.organization.id, projectId); return { access: await listProjectTeamAccess(config.prisma, projectId) }; } catch (err) { return handleRouteError(reply, err); @@ -31,9 +35,13 @@ export async function registerAccessRoutes( app.put("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => { try { const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; - const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + const auth = await requireProjectPermission(request, reply, guardDeps, { + orgSlug, + projectId, + action: "collaborator.manage", + allowOrgAdminOversight: false, + }); if (auth === null) return; - await requireOrgProject(guardDeps, auth.organization.id, projectId); const body = request.body as { teamId?: unknown; teamSlug?: unknown; @@ -67,9 +75,13 @@ export async function registerAccessRoutes( projectId: string; teamId: string; }; - const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + const auth = await requireProjectPermission(request, reply, guardDeps, { + orgSlug, + projectId, + action: "collaborator.manage", + allowOrgAdminOversight: false, + }); if (auth === null) return; - await requireOrgProject(guardDeps, auth.organization.id, projectId); const count = await revokeTeamProjectAccess(config.prisma, { projectId, teamId }); return { revoked: count }; } catch (err) { diff --git a/hub/src/admin/routes/explorerRoutes.ts b/hub/src/admin/routes/explorerRoutes.ts index 7d28f2d..7861ad9 100644 --- a/hub/src/admin/routes/explorerRoutes.ts +++ b/hub/src/admin/routes/explorerRoutes.ts @@ -10,13 +10,15 @@ import { createOrgFolder, createOrgProject, getOrgProjectDetail, + listMyProjects, listOrgExplorer, moveOrgProjectToFolder, renameFolder, renameProject, } from "../../org/explorer.js"; -import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; +import { requireOrgRole, requireProjectPermission, ORG_ADMIN_ROLES, type GuardDeps } from "../auth/guards.js"; import { handleRouteError } from "../errors.js"; +import { createPermissionAuthorizer } from "../../permissions/authorizer.js"; export interface ExplorerRouteConfig { readonly prisma: PrismaClient; @@ -41,6 +43,24 @@ export async function registerExplorerRoutes( } }); + app.get("/api/org/:orgSlug/my-projects", async (request, reply) => { + try { + const { orgSlug } = request.params as { orgSlug: string }; + const auth = await requireOrgRole(request, reply, guardDeps, { + orgSlug, + roles: ["OWNER", "ADMIN", "MEMBER"], + }); + if (auth === null) return; + const projects = await listMyProjects(config.prisma, { + organizationId: auth.organization.id, + actorFeishuOpenId: auth.feishuOpenId, + }); + return { projects }; + } catch (err) { + return handleRouteError(reply, err); + } + }); + app.post("/api/org/:orgSlug/folders", async (request, reply) => { try { const { orgSlug } = request.params as { orgSlug: string }; @@ -144,12 +164,27 @@ export async function registerExplorerRoutes( app.get("/api/org/:orgSlug/projects/:projectId", async (request, reply) => { try { const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string }; - const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); + const auth = await requireProjectPermission(request, reply, guardDeps, { + orgSlug, + projectId, + action: "project.read", + allowOrgAdminOversight: true, + }); if (auth === null) return; - return await getOrgProjectDetail(config.prisma, { + const detail = await getOrgProjectDetail(config.prisma, { organizationId: auth.organization.id, projectId, }); + const manageDecision = await createPermissionAuthorizer(config.prisma).can({ + actor: { feishuOpenId: auth.feishuOpenId }, + action: "collaborator.manage", + resource: { type: "PROJECT", id: projectId }, + }); + return { + ...detail, + actorIsOrgAdmin: ORG_ADMIN_ROLES.includes(auth.membershipRole), + actorCanManageProject: manageDecision.allowed, + }; } catch (err) { return handleRouteError(reply, err); } diff --git a/hub/src/org/explorer.ts b/hub/src/org/explorer.ts index a7842eb..784c479 100644 --- a/hub/src/org/explorer.ts +++ b/hub/src/org/explorer.ts @@ -12,6 +12,7 @@ import { moveProjectToFolder, } from "../projectOnboarding.js"; import { lockActiveOrganization } from "./status.js"; +import { PrismaPrincipalResolver } from "../permissions/principals.js"; export interface ExplorerFolderNode { readonly id: string; @@ -335,6 +336,63 @@ export async function archiveOrgProjectChatBinding( }); } +/** + * Projects an org member can see via their resolved principals' READ+ grants + * (spec `PermissionGrant` / ADR-0004). Used by the member-facing project list + * — org-admin oversight uses `listOrgExplorer` instead. + */ +export async function listMyProjects( + prisma: PrismaClient, + input: { readonly organizationId: string; readonly actorFeishuOpenId: string }, +): Promise { + const resolver = new PrismaPrincipalResolver(prisma); + const resolution = await resolver.resolveActor( + { feishuOpenId: input.actorFeishuOpenId }, + { organizationId: input.organizationId }, + ); + const principalKeys = resolution.principals.map((p) => `${p.type}:${p.id}`); + if (principalKeys.length === 0) return []; + const principalTypes = resolution.principals.map((p) => p.type); + const principalIds = resolution.principals.map((p) => p.id); + const grants = await prisma.permissionGrant.findMany({ + where: { + resourceType: "PROJECT", + revokedAt: null, + principalType: { in: principalTypes }, + principalId: { in: principalIds }, + }, + select: { resourceId: true }, + distinct: ["resourceId"], + }); + const projectIds = grants.map((g) => g.resourceId); + if (projectIds.length === 0) return []; + const projects = await prisma.project.findMany({ + where: { id: { in: projectIds }, organizationId: input.organizationId, archivedAt: null }, + select: { + id: true, + name: true, + folderId: true, + createdAt: true, + groupBindings: { + where: { archivedAt: null }, + select: { chatId: true, createdAt: true }, + take: 1, + }, + }, + orderBy: { name: "asc" }, + }); + return projects.map((p) => { + const binding = p.groupBindings[0]; + return { + id: p.id, + name: p.name, + folderId: p.folderId, + createdAt: p.createdAt.toISOString(), + binding: binding === undefined ? null : { chatId: binding.chatId, createdAt: binding.createdAt.toISOString() }, + }; + }); +} + async function requireActiveFolder( prisma: PrismaClient | Prisma.TransactionClient, folderId: string, diff --git a/hub/test/integration/admin-members-teams.test.ts b/hub/test/integration/admin-members-teams.test.ts index 895d1a7..4eea2c9 100644 --- a/hub/test/integration/admin-members-teams.test.ts +++ b/hub/test/integration/admin-members-teams.test.ts @@ -129,6 +129,18 @@ describe("admin members + teams API", () => { workspaceDir: "/tmp/p1", }, }); + // Team-access grant/revoke is gated to project MANAGE (no org-admin bypass). + // Seed the owner with a MANAGE grant on p1 so the org-owner flow still works. + await prisma.permissionGrant.create({ + data: { + resourceType: "PROJECT", + resourceId: "p1", + principalType: "USER", + principalId: "ou_owner", + role: "MANAGE", + createdByUserId: "u-owner", + }, + }); const app = await buildApp(); try {