This commit is contained in:
2026-08-17 11:30:14 +02:00
parent fe94c2fb9c
commit 321f11e817
5 changed files with 58 additions and 3 deletions
@@ -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.Run("impersonated_member_denied", func(t *testing.T) {
t.Parallel() t.Parallel()
actor := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") actor := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
+22 -1
View File
@@ -66,8 +66,9 @@ func requireCompanyAdmin(w http.ResponseWriter, r *http.Request) bool {
return false 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". // 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 { func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Request) bool {
if CompanyAdminAllowed(r.Context()) { if CompanyAdminAllowed(r.Context()) {
return true return true
@@ -85,6 +86,14 @@ func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Requ
if isAdmin { if isAdmin {
return true 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") Error(w, http.StatusForbidden, "admin required")
return false return false
} }
@@ -424,6 +433,18 @@ func (s *Server) checkPlatformAdmin(ctx context.Context, userID uuid.UUID) (bool
return s.Auth.IsPlatformAdmin(ctx, userID) 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. // checkStaffAccess prefers test hooks, otherwise Auth.GetStaffAccess.
func (s *Server) checkStaffAccess(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error) { func (s *Server) checkStaffAccess(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error) {
if s != nil && s.testStaffAccess != nil { if s != nil && s.testStaffAccess != nil {
+2
View File
@@ -58,6 +58,8 @@ type Server struct {
// testPlatformAdmin optional override for RequirePlatformAdmin unit tests. // testPlatformAdmin optional override for RequirePlatformAdmin unit tests.
testPlatformAdmin func(ctx context.Context, userID uuid.UUID) (bool, error) 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 optional override for RequireSupportDesk / RequirePlatformAdmin tests.
testStaffAccess func(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error) testStaffAccess func(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error)
// testAssertFeatures optional override for requireFeatures / plan-gate unit tests. // testAssertFeatures optional override for requireFeatures / plan-gate unit tests.
+10 -1
View File
@@ -14,6 +14,7 @@ type MeFixture = {
membership?: { role: string; status?: string } | null; membership?: { role: string; status?: string } | null;
staff_access?: MeResponse["staff_access"]; staff_access?: MeResponse["staff_access"];
impersonating?: boolean; impersonating?: boolean;
is_owner?: boolean;
}; };
function me(partial: MeFixture = {}): MeResponse { function me(partial: MeFixture = {}): MeResponse {
@@ -34,7 +35,8 @@ function me(partial: MeFixture = {}): MeResponse {
user, user,
membership, membership,
staff_access: partial.staff_access, 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", () => { it("allows impersonating sessions", () => {
assert.equal( assert.equal(
canManageCompany(me({ membership: { role: "member" }, impersonating: true })), canManageCompany(me({ membership: { role: "member" }, impersonating: true })),
+2 -1
View File
@@ -21,12 +21,13 @@ export function isCompanyAdmin(
/** /**
* True when the session may perform company-admin mutations (API keys, team, company settings). * 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). * (demo/platform actor switched into a member tenant — matches API allowCompanyAdminOrPlatform).
*/ */
export function canManageCompany(me: MeResponse | null | undefined): boolean { export function canManageCompany(me: MeResponse | null | undefined): boolean {
if (me == null) return false; if (me == null) return false;
if (isCompanyAdmin(me)) return true; if (isCompanyAdmin(me)) return true;
if (me.is_owner) return true;
if (isFullPlatformAdmin(me)) { if (isFullPlatformAdmin(me)) {
return true; return true;
} }