forked from bai/curriculum-project-hub
feat(admin): gate project surfaces behind permission grants for members
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.
This commit is contained in:
@@ -122,6 +122,8 @@ export interface ProjectDetail {
|
|||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
createdBy: { id: string; displayName: string; feishuOpenId: string } | null;
|
createdBy: { id: string; displayName: string; feishuOpenId: string } | null;
|
||||||
binding: { chatId: string; createdAt: string } | null;
|
binding: { chatId: string; createdAt: string } | null;
|
||||||
|
actorIsOrgAdmin?: boolean;
|
||||||
|
actorCanManageProject?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamAccessEntry {
|
export interface TeamAccessEntry {
|
||||||
@@ -259,6 +261,8 @@ export const api = {
|
|||||||
post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`),
|
post(`${orgBase(slug)}/teams/${teamId}/members/${userId}/revoke`),
|
||||||
|
|
||||||
explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise<ExplorerData>,
|
explorer: (slug: string) => get(`${orgBase(slug)}/explorer`) as Promise<ExplorerData>,
|
||||||
|
myProjects: (slug: string) =>
|
||||||
|
get(`${orgBase(slug)}/my-projects`) as Promise<{ projects: ExplorerProject[] }>,
|
||||||
createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) =>
|
createFolder: (slug: string, body: { name: string; parentId?: string; sortKey?: string }) =>
|
||||||
post(`${orgBase(slug)}/folders`, body) as Promise<{
|
post(`${orgBase(slug)}/folders`, body) as Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -41,6 +41,11 @@
|
|||||||
return null;
|
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[] {
|
function memberships(): OrgMembership[] {
|
||||||
return $session.me?.organizations ?? [];
|
return $session.me?.organizations ?? [];
|
||||||
}
|
}
|
||||||
@@ -104,22 +109,50 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($session.loading || !$session.me) return;
|
if ($session.loading || !$session.me) return;
|
||||||
const admins = adminOrgs();
|
|
||||||
if (admins.length === 0) return;
|
|
||||||
|
|
||||||
const slug = orgSlugFromPath();
|
const slug = orgSlugFromPath();
|
||||||
const matched = slug ? memberships().find((o) => o.slug === slug) : null;
|
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)) {
|
if (matched && isAdmin(matched)) {
|
||||||
redirecting = false;
|
redirecting = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!matched && admins.length > 0) {
|
||||||
if (!matched) {
|
|
||||||
const target = `/admin/org/${admins[0].slug}`;
|
const target = `/admin/org/${admins[0].slug}`;
|
||||||
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
|
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
|
||||||
redirecting = true;
|
redirecting = true;
|
||||||
void goto(target, { replaceState: 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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -271,6 +304,45 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if currentOrg() && !isAdmin(currentOrg()!) && isOnProjectRoute()}
|
||||||
|
{@const org = currentOrg()!}
|
||||||
|
{@const me = $session.me!}
|
||||||
|
<div class="saas-shell">
|
||||||
|
<div class="saas-main">
|
||||||
|
<header class="saas-topbar">
|
||||||
|
<a
|
||||||
|
href={`/admin/org/${org.slug}/projects`}
|
||||||
|
class="saas-btn-ghost px-2!"
|
||||||
|
aria-label="返回项目列表"
|
||||||
|
>
|
||||||
|
<Icon name="menu" class="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-1.5 text-xs text-surface-600">
|
||||||
|
<span class="truncate">{org.name}</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span class="truncate font-medium text-surface-900">项目</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<span class="saas-badge-neutral font-mono">/{org.slug}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-1.5 text-surface-600 transition hover:bg-surface-200 hover:text-error-700"
|
||||||
|
title="退出登录"
|
||||||
|
onclick={handleLogout}
|
||||||
|
>
|
||||||
|
<Icon name="logout" class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="saas-content">
|
||||||
|
<div class="saas-content-inner">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{:else if currentOrg() && !isAdmin(currentOrg()!)}
|
{:else if currentOrg() && !isAdmin(currentOrg()!)}
|
||||||
{@const denied = currentOrg()!}
|
{@const denied = currentOrg()!}
|
||||||
<div class="saas-status-panel">
|
<div class="saas-status-panel">
|
||||||
@@ -278,37 +350,27 @@
|
|||||||
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
|
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
|
||||||
<p class="mb-3 text-sm text-surface-700">
|
<p class="mb-3 text-sm text-surface-700">
|
||||||
组织 <strong>{denied.name}</strong>(/{denied.slug})中你的角色是
|
组织 <strong>{denied.name}</strong>(/{denied.slug})中你的角色是
|
||||||
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>, 需要<strong>所有者</strong>或<strong
|
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。普通成员仅可访问自己有授权的项目。
|
||||||
>管理员</strong
|
|
||||||
>。
|
|
||||||
</p>
|
</p>
|
||||||
|
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>查看我的项目</a>
|
||||||
{#if memberships().length > 1}
|
{#if memberships().length > 1}
|
||||||
<p class="saas-label text-left mb-1.5">切换到其他组织</p>
|
<p class="saas-label text-left mb-1.5 mt-3">切换到其他组织</p>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<SelectField items={orgSelectItems(memberships())} value={denied.slug} onchange={switchOrg} />
|
<SelectField items={orgSelectItems(memberships())} value={denied.slug} onchange={switchOrg} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="saas-btn-ghost" onclick={handleLogout}>退出登录</button>
|
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if memberships().length > 0}
|
{:else if memberships().length > 0}
|
||||||
{@const denied = pickHomeOrg()!}
|
{@const denied = pickHomeOrg()!}
|
||||||
<div class="saas-status-panel">
|
<div class="saas-status-panel">
|
||||||
<div class="saas-status-card">
|
<div class="saas-status-card">
|
||||||
{#if adminOrgs().length === 0}
|
|
||||||
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
|
|
||||||
<p class="mb-5 text-sm text-surface-700">
|
|
||||||
你加入了 <strong>{denied.name}</strong>,角色是
|
|
||||||
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。仅所有者与管理员可进入后台。
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
|
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
|
||||||
<p class="mb-5 text-sm text-surface-700">
|
<p class="mb-5 text-sm text-surface-700">
|
||||||
即将进入 <strong>{adminOrgs()[0].name}</strong>
|
即将进入 <strong>{denied.name}</strong>(/{denied.slug})的项目。
|
||||||
(/{adminOrgs()[0].slug})。
|
|
||||||
</p>
|
</p>
|
||||||
<a class="saas-btn-primary" href={`/admin/org/${adminOrgs()[0].slug}`}>立即进入</a>
|
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/projects`}>立即进入</a>
|
||||||
{/if}
|
|
||||||
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
|
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api, type ExplorerData, type ExplorerFolder } from '$lib/api';
|
import { api, type ExplorerData, type ExplorerFolder, type ExplorerProject, type OrgMembership } from '$lib/api';
|
||||||
|
import { session } from '$lib/session';
|
||||||
import FolderTree from '$lib/components/FolderTree.svelte';
|
import FolderTree from '$lib/components/FolderTree.svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||||
@@ -9,11 +10,17 @@
|
|||||||
import { Label } from 'bits-ui';
|
import { Label } from 'bits-ui';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import SelectField from '$lib/components/SelectField.svelte';
|
import SelectField from '$lib/components/SelectField.svelte';
|
||||||
|
import { fmtDate } from '$lib/format';
|
||||||
import { toastError, toastSuccess } from '$lib/toast';
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
const slug = $derived(page.params.slug ?? '');
|
const slug = $derived(page.params.slug ?? '');
|
||||||
|
const org = $derived(
|
||||||
|
($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null,
|
||||||
|
);
|
||||||
|
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
|
||||||
|
|
||||||
let data = $state<ExplorerData | null>(null);
|
let data = $state<ExplorerData | null>(null);
|
||||||
|
let myProjects = $state<ExplorerProject[] | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -29,7 +36,13 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
|
if (isAdmin) {
|
||||||
data = await api.explorer(slug);
|
data = await api.explorer(slug);
|
||||||
|
myProjects = null;
|
||||||
|
} else {
|
||||||
|
myProjects = (await api.myProjects(slug)).projects;
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -89,10 +102,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (slug) load();
|
if (slug && org) load();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<LoadingState />
|
||||||
|
{:else if error}
|
||||||
|
<ErrorBanner message={error} onretry={load} />
|
||||||
|
{:else if isAdmin && data}
|
||||||
<PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。">
|
<PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。">
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<button class="saas-btn-secondary" onclick={() => (showFolderModal = true)}>新建文件夹</button>
|
<button class="saas-btn-secondary" onclick={() => (showFolderModal = true)}>新建文件夹</button>
|
||||||
@@ -100,11 +118,6 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<LoadingState />
|
|
||||||
{:else if error}
|
|
||||||
<ErrorBanner message={error} onretry={load} />
|
|
||||||
{:else if data}
|
|
||||||
<div class="saas-card p-2 sm:p-3">
|
<div class="saas-card p-2 sm:p-3">
|
||||||
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0}
|
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0}
|
||||||
<EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" />
|
<EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" />
|
||||||
@@ -112,7 +125,6 @@
|
|||||||
<FolderTree folders={data.folders} projects={data.projects} parentId={null} {slug} />
|
<FolderTree folders={data.folders} projects={data.projects} parentId={null} {slug} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Modal bind:open={showFolderModal} title="新建文件夹">
|
<Modal bind:open={showFolderModal} title="新建文件夹">
|
||||||
<Label.Root class="saas-label" for="folder-name">名称</Label.Root>
|
<Label.Root class="saas-label" for="folder-name">名称</Label.Root>
|
||||||
@@ -157,3 +169,37 @@
|
|||||||
<button class="saas-btn-primary" onclick={createProject}>创建</button>
|
<button class="saas-btn-primary" onclick={createProject}>创建</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
{:else if myProjects !== null}
|
||||||
|
<PageHeader title="我的项目" description="你拥有访问授权的项目。">
|
||||||
|
{#snippet actions()}
|
||||||
|
<span class="saas-badge-neutral">仅显示已授权项目</span>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="saas-card overflow-hidden">
|
||||||
|
{#if myProjects.length === 0}
|
||||||
|
<EmptyState title="暂无可访问项目" description="当团队被授予项目访问权限时,项目会出现在这里。" />
|
||||||
|
{:else}
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>项目</th>
|
||||||
|
<th>飞书群</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each myProjects as p}
|
||||||
|
<tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/org/${slug}/projects/${p.id}`)}>
|
||||||
|
<td class="font-medium">{p.name}</td>
|
||||||
|
<td class="font-mono text-xs">{p.binding ? `群 ${p.binding.chatId}` : '—'}</td>
|
||||||
|
<td class="text-surface-700">{fmtDate(p.createdAt)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<EmptyState title="项目数据不可用" description="无法加载项目列表。" />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -34,23 +34,34 @@
|
|||||||
let grantRole = $state<string>('EDIT');
|
let grantRole = $state<string>('EDIT');
|
||||||
let moveFolder = $state('');
|
let moveFolder = $state('');
|
||||||
|
|
||||||
|
const actorIsOrgAdmin = $derived(proj?.actorIsOrgAdmin ?? false);
|
||||||
|
const actorCanManage = $derived(proj?.actorCanManageProject ?? false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const [p, a, s, t, e] = await Promise.all([
|
// Project detail + team-access list are gated to project read/oversight.
|
||||||
api.project(slug, projectId),
|
const [p, a] = await Promise.all([api.project(slug, projectId), api.teamAccess(slug, projectId)]);
|
||||||
api.teamAccess(slug, projectId),
|
proj = p;
|
||||||
|
access = a.access;
|
||||||
|
// 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.sessions(slug, projectId),
|
||||||
api.teams(slug),
|
api.teams(slug),
|
||||||
api.explorer(slug),
|
api.explorer(slug),
|
||||||
]);
|
]);
|
||||||
proj = p;
|
|
||||||
access = a.access;
|
|
||||||
sessions = s.sessions;
|
sessions = s.sessions;
|
||||||
teams = t.teams;
|
teams = t.teams;
|
||||||
explorer = e;
|
explorer = e;
|
||||||
moveFolder = p.folderId ?? '';
|
moveFolder = p.folderId ?? '';
|
||||||
|
} else {
|
||||||
|
sessions = [];
|
||||||
|
teams = [];
|
||||||
|
explorer = null;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -157,11 +168,13 @@
|
|||||||
{@const detail = proj}
|
{@const detail = proj}
|
||||||
<PageHeader title={detail.name} description={`项目是权限边界;通过团队授予 ${roleChain}。`}>
|
<PageHeader title={detail.name} description={`项目是权限边界;通过团队授予 ${roleChain}。`}>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
|
{#if actorIsOrgAdmin}
|
||||||
<button class="saas-btn-secondary py-1.5! text-sm" onclick={rename}>重命名</button>
|
<button class="saas-btn-secondary py-1.5! text-sm" onclick={rename}>重命名</button>
|
||||||
{#if detail.binding}
|
{#if detail.binding}
|
||||||
<button class="saas-btn-secondary py-1.5! text-sm" onclick={archiveBinding}>解绑飞书群</button>
|
<button class="saas-btn-secondary py-1.5! text-sm" onclick={archiveBinding}>解绑飞书群</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="saas-btn-danger py-1.5! text-sm" onclick={archiveProject}>归档</button>
|
<button class="saas-btn-danger py-1.5! text-sm" onclick={archiveProject}>归档</button>
|
||||||
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
@@ -214,11 +227,15 @@
|
|||||||
<h3 class="saas-section-title mb-1">团队授权</h3>
|
<h3 class="saas-section-title mb-1">团队授权</h3>
|
||||||
<p class="saas-muted mb-4">通过团队授权项目访问。一项目可授多团队,一团队可访问多项目。</p>
|
<p class="saas-muted mb-4">通过团队授权项目访问。一项目可授多团队,一团队可访问多项目。</p>
|
||||||
|
|
||||||
|
{#if actorCanManage}
|
||||||
<div class="mb-4 grid gap-2 sm:grid-cols-[1fr_10rem_auto]">
|
<div class="mb-4 grid gap-2 sm:grid-cols-[1fr_10rem_auto]">
|
||||||
<SelectField items={teamItems()} bind:value={grantTeam} />
|
<SelectField items={teamItems()} bind:value={grantTeam} />
|
||||||
<SelectField items={roleItems} bind:value={grantRole} />
|
<SelectField items={roleItems} bind:value={grantRole} />
|
||||||
<button class="saas-btn-primary" onclick={grant}>授权</button>
|
<button class="saas-btn-primary" onclick={grant}>授权</button>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-4 text-xs text-surface-600">需要项目 MANAGE 授权才能增删团队访问。</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if access.length === 0}
|
{#if access.length === 0}
|
||||||
<EmptyState title="暂无团队授权" description="选择团队并授予角色以开放项目访问。" />
|
<EmptyState title="暂无团队授权" description="选择团队并授予角色以开放项目访问。" />
|
||||||
@@ -239,7 +256,11 @@
|
|||||||
<td class="font-mono text-xs">/{g.teamSlug}</td>
|
<td class="font-mono text-xs">/{g.teamSlug}</td>
|
||||||
<td><span class="saas-badge-primary">{permissionRoleLabel(g.role)}</span></td>
|
<td><span class="saas-badge-primary">{permissionRoleLabel(g.role)}</span></td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
|
{#if actorCanManage}
|
||||||
<button class="saas-btn-danger py-1! text-xs" onclick={() => revoke(g)}>撤销</button>
|
<button class="saas-btn-danger py-1! text-xs" onclick={() => revoke(g)}>撤销</button>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-surface-500">—</span>
|
||||||
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -248,6 +269,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if actorIsOrgAdmin}
|
||||||
<div class="saas-card overflow-hidden">
|
<div class="saas-card overflow-hidden">
|
||||||
<div class="border-b border-surface-200 px-5 py-3">
|
<div class="border-b border-surface-200 px-5 py-3">
|
||||||
<h3 class="text-sm font-semibold">智能体会话</h3>
|
<h3 class="text-sm font-semibold">智能体会话</h3>
|
||||||
@@ -278,3 +300,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="saas-empty">
|
||||||
|
<p class="text-sm text-surface-600">项目数据不可用</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import {
|
|||||||
verifySession,
|
verifySession,
|
||||||
type SessionPayload,
|
type SessionPayload,
|
||||||
} from "./session.js";
|
} from "./session.js";
|
||||||
|
import { createPermissionAuthorizer, type AuthorizationAction } from "../../permissions/authorizer.js";
|
||||||
|
|
||||||
export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"];
|
export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"];
|
||||||
|
const ANY_ORG_ROLE: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN", "MEMBER"];
|
||||||
|
|
||||||
export interface AuthContext {
|
export interface AuthContext {
|
||||||
readonly session: SessionPayload;
|
readonly session: SessionPayload;
|
||||||
@@ -175,3 +177,55 @@ export async function sendError(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await reply.status(statusCode).send({ error: { code, message } });
|
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<ProjectAuthContext | null> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
listProjectTeamAccess,
|
listProjectTeamAccess,
|
||||||
revokeTeamProjectAccess,
|
revokeTeamProjectAccess,
|
||||||
} from "../../permissions/projectTeamAccess.js";
|
} 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";
|
import { handleRouteError } from "../errors.js";
|
||||||
|
|
||||||
const ROLES: readonly PermissionRole[] = ["READ", "EDIT", "MANAGE"];
|
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) => {
|
app.get("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
|
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;
|
if (auth === null) return;
|
||||||
await requireOrgProject(guardDeps, auth.organization.id, projectId);
|
|
||||||
return { access: await listProjectTeamAccess(config.prisma, projectId) };
|
return { access: await listProjectTeamAccess(config.prisma, projectId) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return handleRouteError(reply, 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) => {
|
app.put("/api/org/:orgSlug/projects/:projectId/team-access", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
|
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;
|
if (auth === null) return;
|
||||||
await requireOrgProject(guardDeps, auth.organization.id, projectId);
|
|
||||||
const body = request.body as {
|
const body = request.body as {
|
||||||
teamId?: unknown;
|
teamId?: unknown;
|
||||||
teamSlug?: unknown;
|
teamSlug?: unknown;
|
||||||
@@ -67,9 +75,13 @@ export async function registerAccessRoutes(
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
teamId: 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;
|
if (auth === null) return;
|
||||||
await requireOrgProject(guardDeps, auth.organization.id, projectId);
|
|
||||||
const count = await revokeTeamProjectAccess(config.prisma, { projectId, teamId });
|
const count = await revokeTeamProjectAccess(config.prisma, { projectId, teamId });
|
||||||
return { revoked: count };
|
return { revoked: count };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ import {
|
|||||||
createOrgFolder,
|
createOrgFolder,
|
||||||
createOrgProject,
|
createOrgProject,
|
||||||
getOrgProjectDetail,
|
getOrgProjectDetail,
|
||||||
|
listMyProjects,
|
||||||
listOrgExplorer,
|
listOrgExplorer,
|
||||||
moveOrgProjectToFolder,
|
moveOrgProjectToFolder,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
renameProject,
|
renameProject,
|
||||||
} from "../../org/explorer.js";
|
} 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 { handleRouteError } from "../errors.js";
|
||||||
|
import { createPermissionAuthorizer } from "../../permissions/authorizer.js";
|
||||||
|
|
||||||
export interface ExplorerRouteConfig {
|
export interface ExplorerRouteConfig {
|
||||||
readonly prisma: PrismaClient;
|
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) => {
|
app.post("/api/org/:orgSlug/folders", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const { orgSlug } = request.params as { orgSlug: string };
|
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) => {
|
app.get("/api/org/:orgSlug/projects/:projectId", async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const { orgSlug, projectId } = request.params as { orgSlug: string; projectId: string };
|
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;
|
if (auth === null) return;
|
||||||
return await getOrgProjectDetail(config.prisma, {
|
const detail = await getOrgProjectDetail(config.prisma, {
|
||||||
organizationId: auth.organization.id,
|
organizationId: auth.organization.id,
|
||||||
projectId,
|
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) {
|
} catch (err) {
|
||||||
return handleRouteError(reply, err);
|
return handleRouteError(reply, err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
moveProjectToFolder,
|
moveProjectToFolder,
|
||||||
} from "../projectOnboarding.js";
|
} from "../projectOnboarding.js";
|
||||||
import { lockActiveOrganization } from "./status.js";
|
import { lockActiveOrganization } from "./status.js";
|
||||||
|
import { PrismaPrincipalResolver } from "../permissions/principals.js";
|
||||||
|
|
||||||
export interface ExplorerFolderNode {
|
export interface ExplorerFolderNode {
|
||||||
readonly id: string;
|
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<readonly ExplorerProjectNode[]> {
|
||||||
|
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(
|
async function requireActiveFolder(
|
||||||
prisma: PrismaClient | Prisma.TransactionClient,
|
prisma: PrismaClient | Prisma.TransactionClient,
|
||||||
folderId: string,
|
folderId: string,
|
||||||
|
|||||||
@@ -129,6 +129,18 @@ describe("admin members + teams API", () => {
|
|||||||
workspaceDir: "/tmp/p1",
|
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();
|
const app = await buildApp();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user