From 321f11e81785abfa6b1d1b63c46c9aa3c73af44a Mon Sep 17 00:00:00 2001 From: GreenEclipse Date: Mon, 17 Aug 2026 11:30:14 +0200 Subject: [PATCH] fix --- .../httpapi/company_member_role_test.go | 22 ++++++++++++++++++ apps/api/internal/httpapi/middleware.go | 23 ++++++++++++++++++- apps/api/internal/httpapi/server.go | 2 ++ apps/web/src/lib/company-admin.test.ts | 11 ++++++++- apps/web/src/lib/company-admin.ts | 3 ++- 5 files changed, 58 insertions(+), 3 deletions(-) diff --git a/apps/api/internal/httpapi/company_member_role_test.go b/apps/api/internal/httpapi/company_member_role_test.go index 4d40a84..754dc25 100644 --- a/apps/api/internal/httpapi/company_member_role_test.go +++ b/apps/api/internal/httpapi/company_member_role_test.go @@ -152,6 +152,28 @@ func TestAllowCompanyAdminOrPlatform(t *testing.T) { } }) + t.Run("company_owner_member_role", func(t *testing.T) { + t.Parallel() + s := &Server{ + testPlatformAdmin: func(context.Context, uuid.UUID) (bool, error) { + return false, nil + }, + testCompanyOwner: func(_ context.Context, got uuid.UUID) (bool, error) { + if got != uid { + t.Fatalf("userID = %s, want %s", got, uid) + } + return true, nil + }, + } + ctx := context.WithValue(context.Background(), ctxUserID, uid) + ctx = context.WithValue(ctx, ctxRole, "member") + req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + rec := httptest.NewRecorder() + if !s.allowCompanyAdminOrPlatform(rec, req) { + t.Fatal("company owner with membership role=member must be allowed to invite") + } + }) + t.Run("impersonated_member_denied", func(t *testing.T) { t.Parallel() actor := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") diff --git a/apps/api/internal/httpapi/middleware.go b/apps/api/internal/httpapi/middleware.go index a14d12f..97369fd 100644 --- a/apps/api/internal/httpapi/middleware.go +++ b/apps/api/internal/httpapi/middleware.go @@ -66,8 +66,9 @@ func requireCompanyAdmin(w http.ResponseWriter, r *http.Request) bool { return false } -// allowCompanyAdminOrPlatform allows company admins, API keys, or platform admins. +// allowCompanyAdminOrPlatform allows company admins, API keys, company owners, or platform admins. // Platform admins can manage team after migration when all memberships are still "member". +// Company owners (owner_user_id) keep team powers even when membership role is member. func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Request) bool { if CompanyAdminAllowed(r.Context()) { return true @@ -85,6 +86,14 @@ func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Requ if isAdmin { return true } + isOwner, err := s.checkCompanyOwner(r.Context(), uid) + if err != nil { + Error(w, http.StatusInternalServerError, "authorization check failed") + return false + } + if isOwner { + return true + } Error(w, http.StatusForbidden, "admin required") return false } @@ -424,6 +433,18 @@ func (s *Server) checkPlatformAdmin(ctx context.Context, userID uuid.UUID) (bool return s.Auth.IsPlatformAdmin(ctx, userID) } +// checkCompanyOwner prefers an optional test hook, otherwise Auth.IsCompanyOwner. +func (s *Server) checkCompanyOwner(ctx context.Context, userID uuid.UUID) (bool, error) { + if s != nil && s.testCompanyOwner != nil { + return s.testCompanyOwner(ctx, userID) + } + cid, ok := CompanyIDFromContext(ctx) + if !ok || s == nil || s.Auth == nil || s.Auth.Pool == nil { + return false, nil + } + return s.Auth.IsCompanyOwner(ctx, cid, userID) +} + // checkStaffAccess prefers test hooks, otherwise Auth.GetStaffAccess. func (s *Server) checkStaffAccess(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error) { if s != nil && s.testStaffAccess != nil { diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index b248733..4dc1fd8 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -58,6 +58,8 @@ type Server struct { // testPlatformAdmin optional override for RequirePlatformAdmin unit tests. testPlatformAdmin func(ctx context.Context, userID uuid.UUID) (bool, error) + // testCompanyOwner optional override for allowCompanyAdminOrPlatform owner checks. + testCompanyOwner func(ctx context.Context, userID uuid.UUID) (bool, error) // testStaffAccess optional override for RequireSupportDesk / RequirePlatformAdmin tests. testStaffAccess func(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error) // testAssertFeatures optional override for requireFeatures / plan-gate unit tests. diff --git a/apps/web/src/lib/company-admin.test.ts b/apps/web/src/lib/company-admin.test.ts index 7a8180b..91403ec 100644 --- a/apps/web/src/lib/company-admin.test.ts +++ b/apps/web/src/lib/company-admin.test.ts @@ -14,6 +14,7 @@ type MeFixture = { membership?: { role: string; status?: string } | null; staff_access?: MeResponse["staff_access"]; impersonating?: boolean; + is_owner?: boolean; }; function me(partial: MeFixture = {}): MeResponse { @@ -34,7 +35,8 @@ function me(partial: MeFixture = {}): MeResponse { user, membership, staff_access: partial.staff_access, - impersonating: partial.impersonating + impersonating: partial.impersonating, + is_owner: partial.is_owner }; } @@ -77,6 +79,13 @@ describe("canManageCompany", () => { ); }); + it("allows company owner without company admin role", () => { + assert.equal( + canManageCompany(me({ membership: { role: "member" }, is_owner: true })), + true + ); + }); + it("allows impersonating sessions", () => { assert.equal( canManageCompany(me({ membership: { role: "member" }, impersonating: true })), diff --git a/apps/web/src/lib/company-admin.ts b/apps/web/src/lib/company-admin.ts index 5e85f71..e0c4326 100644 --- a/apps/web/src/lib/company-admin.ts +++ b/apps/web/src/lib/company-admin.ts @@ -21,12 +21,13 @@ export function isCompanyAdmin( /** * True when the session may perform company-admin mutations (API keys, team, company settings). - * Includes membership admin, platform/full admin, and non-prod privileged impersonation + * Includes membership admin, company owner, platform/full admin, and non-prod privileged impersonation * (demo/platform actor switched into a member tenant — matches API allowCompanyAdminOrPlatform). */ export function canManageCompany(me: MeResponse | null | undefined): boolean { if (me == null) return false; if (isCompanyAdmin(me)) return true; + if (me.is_owner) return true; if (isFullPlatformAdmin(me)) { return true; }