You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -30,24 +30,39 @@ func NewProjectService(repo Repository, rec audit.Recorder) ProjectService {
|
||||
return &projectService{repo: repo, audit: rec}
|
||||
}
|
||||
|
||||
// assertReadable 是 read 类操作的鉴权门面:private 仅 owner+admin,internal
|
||||
// 任意登录用户。失败统一返回 CodeNotFound(避免存在性探测)。
|
||||
func assertReadable(p *Project, c Caller) error {
|
||||
// assertReadable 是 read 类操作的鉴权门面。成员/角色 ACL(autonomy roadmap §11)
|
||||
// 在既有规则之上"叠加"成员授权(只增不减):
|
||||
// - internal 可见性:任意登录用户可读(既有)
|
||||
// - owner / global-admin:可读(既有)
|
||||
// - 任何成员角色(viewer/member/admin):可读(新增叠加)
|
||||
//
|
||||
// 失败统一返回 CodeNotFound(避免存在性探测)。role 由 service 在调用前查得,
|
||||
// 非成员传 RoleNone。
|
||||
func assertReadable(p *Project, c Caller, role Role) error {
|
||||
if p.Visibility == VisibilityInternal {
|
||||
return nil
|
||||
}
|
||||
if c.IsAdmin || p.OwnerID == c.UserID {
|
||||
return nil
|
||||
}
|
||||
if role.CanRead() {
|
||||
return nil
|
||||
}
|
||||
return errs.New(errs.CodeNotFound, "project 不存在")
|
||||
}
|
||||
|
||||
// assertWritable 是 write 类操作的鉴权门面:仅 owner+admin。已归档项目额外
|
||||
// 拒绝;调用方应在执行写入前调用此函数。
|
||||
func assertWritable(p *Project, c Caller) error {
|
||||
if !c.IsAdmin && p.OwnerID != c.UserID {
|
||||
// stranger 对 private 看不见;对 internal 也不能写。统一 not_found 防探测。
|
||||
if p.Visibility == VisibilityPrivate {
|
||||
// assertWritable 是 write 类操作的鉴权门面。成员/角色 ACL 叠加写授权:
|
||||
// - owner / global-admin:可写(既有)
|
||||
// - member / admin 成员角色:可写(新增叠加)
|
||||
// - viewer / 非成员:不可写
|
||||
//
|
||||
// 已归档项目额外拒绝;调用方应在执行写入前调用此函数。role 由 service 查得。
|
||||
func assertWritable(p *Project, c Caller, role Role) error {
|
||||
if !c.IsAdmin && p.OwnerID != c.UserID && !role.CanWrite() {
|
||||
// stranger 对 private 看不见;对 internal/viewer 也不能写。统一 not_found
|
||||
// 防探测——但若 caller 至少能读(internal 可见性或 viewer 成员),暴露
|
||||
// forbidden 比 not_found 更准确(其存在性本就可见)。
|
||||
if p.Visibility == VisibilityPrivate && !role.CanRead() {
|
||||
return errs.New(errs.CodeNotFound, "project 不存在")
|
||||
}
|
||||
return errs.New(errs.CodeForbidden, "无权修改该项目")
|
||||
@@ -58,6 +73,21 @@ func assertWritable(p *Project, c Caller) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// memberRoleFor 查 caller 在 project 的成员角色。owner/global-admin 隐式视为
|
||||
// RoleAdmin(与回填一致,避免对 owner 多查一次也无妨——这里仍走 DB 但 owner
|
||||
// 经迁移已有 admin 行)。查询失败按 RoleNone 处理(best-effort,既有 owner/admin
|
||||
// 规则仍兜底)。
|
||||
func (s *projectService) memberRoleFor(ctx context.Context, p *Project, c Caller) Role {
|
||||
if c.IsAdmin || p.OwnerID == c.UserID {
|
||||
return RoleAdmin
|
||||
}
|
||||
role, err := s.repo.GetMemberRole(ctx, p.ID, c.UserID)
|
||||
if err != nil {
|
||||
return RoleNone
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
func (s *projectService) Create(ctx context.Context, c Caller, in CreateProjectInput) (*Project, error) {
|
||||
if in.Slug == "" || in.Name == "" {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "slug / name 必填")
|
||||
@@ -87,7 +117,7 @@ func (s *projectService) Get(ctx context.Context, c Caller, slug string) (*Proje
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertReadable(p, c); err != nil {
|
||||
if err := assertReadable(p, c, s.memberRoleFor(ctx, p, c)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
@@ -100,7 +130,7 @@ func (s *projectService) GetByID(ctx context.Context, c Caller, id uuid.UUID) (*
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertReadable(p, c); err != nil {
|
||||
if err := assertReadable(p, c, s.memberRoleFor(ctx, p, c)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
@@ -113,7 +143,13 @@ func (s *projectService) List(ctx context.Context, c Caller, includeArchived boo
|
||||
}
|
||||
out := make([]*Project, 0, len(all))
|
||||
for _, p := range all {
|
||||
if assertReadable(p, c) == nil {
|
||||
// 列表场景:internal/owner/admin 命中即收,避免对每个 private 项目都查一次
|
||||
// 成员表;仅当上述既有规则不命中时才回退查成员角色(叠加 viewer/member)。
|
||||
if assertReadable(p, c, RoleNone) == nil {
|
||||
out = append(out, p)
|
||||
continue
|
||||
}
|
||||
if role := s.memberRoleFor(ctx, p, c); assertReadable(p, c, role) == nil {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
@@ -125,7 +161,7 @@ func (s *projectService) Update(ctx context.Context, c Caller, slug string, in U
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertWritable(p, c); err != nil {
|
||||
if err := assertWritable(p, c, s.memberRoleFor(ctx, p, c)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changed := map[string]any{}
|
||||
@@ -160,8 +196,9 @@ func (s *projectService) Archive(ctx context.Context, c Caller, slug string) err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 归档/取消归档仍要 owner+admin 校验,但已归档不再额外阻塞 archive。
|
||||
if !c.IsAdmin && p.OwnerID != c.UserID {
|
||||
// 归档/取消归档要 owner / global-admin / project-admin 角色,但已归档不再额外
|
||||
// 阻塞 archive。project-admin 视为项目管理者,可执行生命周期操作。
|
||||
if !c.IsAdmin && p.OwnerID != c.UserID && !s.memberRoleFor(ctx, p, c).CanManageMembers() {
|
||||
if p.Visibility == VisibilityPrivate {
|
||||
return errs.New(errs.CodeNotFound, "project 不存在")
|
||||
}
|
||||
@@ -182,7 +219,7 @@ func (s *projectService) Unarchive(ctx context.Context, c Caller, slug string) e
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !c.IsAdmin && p.OwnerID != c.UserID {
|
||||
if !c.IsAdmin && p.OwnerID != c.UserID && !s.memberRoleFor(ctx, p, c).CanManageMembers() {
|
||||
if p.Visibility == VisibilityPrivate {
|
||||
return errs.New(errs.CodeNotFound, "project 不存在")
|
||||
}
|
||||
@@ -198,6 +235,101 @@ func (s *projectService) Unarchive(ctx context.Context, c Caller, slug string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// ===== 成员/角色 ACL(autonomy roadmap §11)=====
|
||||
|
||||
// ListMembers 返回 slug 项目的全部成员。要求 caller 对项目可读(owner/admin/
|
||||
// 任意成员/internal 可见性)。
|
||||
func (s *projectService) ListMembers(ctx context.Context, c Caller, slug string) ([]*Member, error) {
|
||||
p, err := s.repo.GetProjectBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertReadable(p, c, s.memberRoleFor(ctx, p, c)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListMembers(ctx, p.ID)
|
||||
}
|
||||
|
||||
// AddMember 新增或更新一名成员的角色。仅 project-admin 角色或 global-admin 可调用。
|
||||
func (s *projectService) AddMember(ctx context.Context, c Caller, slug string, in AddMemberInput) (*Member, error) {
|
||||
if in.UserID == uuid.Nil {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "user_id 必填")
|
||||
}
|
||||
if !in.Role.IsValid() {
|
||||
return nil, errs.New(errs.CodeInvalidInput, "role 非法(仅 viewer/member/admin)")
|
||||
}
|
||||
p, err := s.repo.GetProjectBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.assertCanManageMembers(ctx, p, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addedBy := c.UserID
|
||||
m, err := s.repo.UpsertMember(ctx, p.ID, in.UserID, in.Role, &addedBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.recordAudit(ctx, c, "project.member_add", p.ID.String(), map[string]any{
|
||||
"user_id": in.UserID.String(), "role": string(in.Role),
|
||||
})
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// RemoveMember 移除一名成员。仅 project-admin 角色或 global-admin 可调用。
|
||||
// 拒绝移除项目 owner(owner 经回填为 admin,移除会让其失去隐式 owner 之外的角色行,
|
||||
// 但 owner 仍由 OwnerID 兜底拥有权限——为避免误导,直接拒绝移除 owner)。
|
||||
func (s *projectService) RemoveMember(ctx context.Context, c Caller, slug string, userID uuid.UUID) error {
|
||||
if userID == uuid.Nil {
|
||||
return errs.New(errs.CodeInvalidInput, "user_id 必填")
|
||||
}
|
||||
p, err := s.repo.GetProjectBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.assertCanManageMembers(ctx, p, c); err != nil {
|
||||
return err
|
||||
}
|
||||
if userID == p.OwnerID {
|
||||
return errs.New(errs.CodeForbidden, "不能移除项目 owner")
|
||||
}
|
||||
n, err := s.repo.DeleteMember(ctx, p.ID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return errs.New(errs.CodeNotFound, "成员不存在")
|
||||
}
|
||||
s.recordAudit(ctx, c, "project.member_remove", p.ID.String(), map[string]any{
|
||||
"user_id": userID.String(),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemberRole 返回 userID 在 projectID 的成员角色(非成员返回 RoleNone)。
|
||||
// owner/global-admin 的隐式 admin 不在此体现——本方法只读 project_members 行;
|
||||
// app 层 projectACL 自行叠加 owner/admin 判定。
|
||||
func (s *projectService) MemberRole(ctx context.Context, projectID, userID uuid.UUID) (Role, error) {
|
||||
return s.repo.GetMemberRole(ctx, projectID, userID)
|
||||
}
|
||||
|
||||
// assertCanManageMembers 校验 caller 可管理 p 的成员:global-admin、owner 或
|
||||
// project-admin 角色。private 不可见者返回 NotFound 防探测。
|
||||
func (s *projectService) assertCanManageMembers(ctx context.Context, p *Project, c Caller) error {
|
||||
if c.IsAdmin || p.OwnerID == c.UserID {
|
||||
return nil
|
||||
}
|
||||
role := s.memberRoleFor(ctx, p, c)
|
||||
if role.CanManageMembers() {
|
||||
return nil
|
||||
}
|
||||
// 不可读者:private 返回 NotFound 防探测;否则 Forbidden。
|
||||
if assertReadable(p, c, role) != nil {
|
||||
return errs.New(errs.CodeNotFound, "project 不存在")
|
||||
}
|
||||
return errs.New(errs.CodeForbidden, "无权管理项目成员")
|
||||
}
|
||||
|
||||
// recordAudit 是 best-effort 写入:失败仅吞错(不影响业务)。ctx 用
|
||||
// context.WithoutCancel 派生,使 HTTP 响应已返回但审计仍能落库。
|
||||
func (s *projectService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) {
|
||||
|
||||
Reference in New Issue
Block a user