From 5fbfc057a4c7da13cdb3fe40d22d8ecc9f5a0f94 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Wed, 8 Apr 2026 14:34:37 +0200 Subject: [PATCH 1/4] feat(organization): add suspension support Signed-off-by: Sylwester Piskozub --- app/controlplane/internal/server/grpc.go | 4 + .../usercontext/apitoken_middleware.go | 4 +- .../currentorganization_middleware.go | 6 +- .../usercontext/entities/organization.go | 1 + .../usercontext/federated_middleware.go | 2 +- .../usercontext/robotaccount_middleware.go | 2 +- .../usercontext/suspension_middleware.go | 85 +++++++ .../usercontext/suspension_middleware_test.go | 218 ++++++++++++++++++ app/controlplane/pkg/authz/authz.go | 30 +++ .../pkg/authz/middleware/middleware.go | 26 +-- .../pkg/authz/middleware/middleware_test.go | 2 +- .../pkg/authz/policies_lookup_test.go | 92 ++++++++ .../pkg/biz/mocks/OrganizationRepo.go | 63 +++++ app/controlplane/pkg/biz/organization.go | 3 + .../ent/migrate/migrations/20260408122048.sql | 2 + .../pkg/data/ent/migrate/migrations/atlas.sum | 3 +- .../pkg/data/ent/migrate/schema.go | 1 + app/controlplane/pkg/data/ent/mutation.go | 56 ++++- app/controlplane/pkg/data/ent/organization.go | 13 +- .../pkg/data/ent/organization/organization.go | 10 + .../pkg/data/ent/organization/where.go | 15 ++ .../pkg/data/ent/organization_create.go | 65 ++++++ .../pkg/data/ent/organization_update.go | 34 +++ app/controlplane/pkg/data/ent/runtime.go | 4 + .../pkg/data/ent/schema/organization.go | 2 + app/controlplane/pkg/data/organization.go | 10 + 26 files changed, 717 insertions(+), 36 deletions(-) create mode 100644 app/controlplane/internal/usercontext/suspension_middleware.go create mode 100644 app/controlplane/internal/usercontext/suspension_middleware_test.go create mode 100644 app/controlplane/pkg/authz/policies_lookup_test.go create mode 100644 app/controlplane/pkg/data/ent/migrate/migrations/20260408122048.sql diff --git a/app/controlplane/internal/server/grpc.go b/app/controlplane/internal/server/grpc.go index cebc15699..739487c09 100644 --- a/app/controlplane/internal/server/grpc.go +++ b/app/controlplane/internal/server/grpc.go @@ -211,6 +211,8 @@ func craftMiddleware(opts *Opts) []middleware.Middleware { selector.Server( // 2.d- Set its organization usercontext.WithCurrentOrganizationMiddleware(opts.UserUseCase, opts.OrganizationUseCase, logHelper), + // 2.e- Block write operations on suspended orgs + usercontext.WithSuspensionMiddleware(), // 3 - Check user/token authorization authzMiddleware.WithAuthzMiddleware(opts.AuthzUseCase, logHelper), ).Match(requireAllButOrganizationOperationsMatcher()).Build(), @@ -251,6 +253,8 @@ func craftMiddleware(opts *Opts) []middleware.Middleware { usercontext.WithAttestationContextFromFederatedInfo(opts.OrganizationUseCase, logHelper), // Store all memberships in the context usercontext.WithCurrentMembershipsMiddleware(opts.MembershipUseCase, opts.MembershipsCache), + // 2.e - Block write operations on suspended orgs + usercontext.WithSuspensionMiddleware(), // 3 - Update API Token last usage usercontext.WithAPITokenUsageUpdater(opts.APITokenUseCase, logHelper), // 4 - Validate the CAS Backend is fully configured and valid diff --git a/app/controlplane/internal/usercontext/apitoken_middleware.go b/app/controlplane/internal/usercontext/apitoken_middleware.go index eb013dc34..5de1dc284 100644 --- a/app/controlplane/internal/usercontext/apitoken_middleware.go +++ b/app/controlplane/internal/usercontext/apitoken_middleware.go @@ -200,7 +200,7 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa return nil, errors.New("organization not found") } - ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt}) + ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt, Suspended: org.Suspended}) } // If no org header, org context remains unset, operations will either: // 1. Work without org context @@ -214,7 +214,7 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa } // Set the current organization in the context - ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt}) + ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt, Suspended: org.Suspended}) } ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{ diff --git a/app/controlplane/internal/usercontext/currentorganization_middleware.go b/app/controlplane/internal/usercontext/currentorganization_middleware.go index 2da646949..87945524d 100644 --- a/app/controlplane/internal/usercontext/currentorganization_middleware.go +++ b/app/controlplane/internal/usercontext/currentorganization_middleware.go @@ -156,7 +156,7 @@ func setCurrentMembershipFromOrgName(ctx context.Context, user *entities.User, o role = authz.RoleInstanceAdmin } else { role = membership.Role - ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: membership.Org.Name, ID: membership.Org.ID, CreatedAt: membership.CreatedAt}) + ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: membership.Org.Name, ID: membership.Org.ID, CreatedAt: membership.CreatedAt, Suspended: membership.Org.Suspended}) } // Set the authorization subject that will be used to check the policies @@ -175,7 +175,7 @@ func setMembershipIfInstanceAdmin(ctx context.Context, orgName string, orgUC *bi if err != nil { return nil, fmt.Errorf("failed to find organization: %w", err) } - ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt}) + ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt, Suspended: org.Suspended}) } } else { // if no membership and no instance admin, return error @@ -202,7 +202,7 @@ func setCurrentOrganizationFromDB(ctx context.Context, user *entities.User, user return nil, errors.New("org not found") } - ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: membership.Org.Name, ID: membership.Org.ID, CreatedAt: membership.CreatedAt}) + ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: membership.Org.Name, ID: membership.Org.ID, CreatedAt: membership.CreatedAt, Suspended: membership.Org.Suspended}) // Set the authorization subject that will be used to check the policies ctx = WithAuthzSubject(ctx, string(membership.Role)) diff --git a/app/controlplane/internal/usercontext/entities/organization.go b/app/controlplane/internal/usercontext/entities/organization.go index 2afc7cfd7..4ddfda27b 100644 --- a/app/controlplane/internal/usercontext/entities/organization.go +++ b/app/controlplane/internal/usercontext/entities/organization.go @@ -23,6 +23,7 @@ import ( type Org struct { ID, Name string CreatedAt *time.Time + Suspended bool } func WithCurrentOrg(ctx context.Context, org *Org) context.Context { diff --git a/app/controlplane/internal/usercontext/federated_middleware.go b/app/controlplane/internal/usercontext/federated_middleware.go index 4d532fe5a..d568dd44e 100644 --- a/app/controlplane/internal/usercontext/federated_middleware.go +++ b/app/controlplane/internal/usercontext/federated_middleware.go @@ -61,7 +61,7 @@ func WithAttestationContextFromFederatedInfo(orgUC *biz.OrganizationUseCase, log } // Set the current organization and API-Token in the context - ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt}) + ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt, Suspended: org.Suspended}) logger.Infow("msg", "[authN] processed credentials", "type", "Federated delegation") return handler(ctx, req) diff --git a/app/controlplane/internal/usercontext/robotaccount_middleware.go b/app/controlplane/internal/usercontext/robotaccount_middleware.go index 2b394124d..2c446517b 100644 --- a/app/controlplane/internal/usercontext/robotaccount_middleware.go +++ b/app/controlplane/internal/usercontext/robotaccount_middleware.go @@ -111,7 +111,7 @@ func WithAttestationContextFromRobotAccount(robotAccountUseCase *biz.RobotAccoun return nil, fmt.Errorf("error retrieving the organization: %w", err) } - ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt}) + ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt, Suspended: org.Suspended}) // Check that the encoded workflow ID is the one associated with the robot account // NOTE: This in theory should not be necessary since currently we allow a robot account to be attached to ONLY ONE workflowID diff --git a/app/controlplane/internal/usercontext/suspension_middleware.go b/app/controlplane/internal/usercontext/suspension_middleware.go new file mode 100644 index 000000000..5a52a134a --- /dev/null +++ b/app/controlplane/internal/usercontext/suspension_middleware.go @@ -0,0 +1,85 @@ +package usercontext + +import ( + "context" + "regexp" + + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" + errorsAPI "github.com/go-kratos/kratos/v2/errors" + "github.com/go-kratos/kratos/v2/middleware" + "github.com/go-kratos/kratos/v2/transport" +) + +// readOnlyActions are policy actions considered safe during suspension. +var readOnlyActions = map[string]bool{ + authz.ActionRead: true, + authz.ActionList: true, +} + +// suspensionExemptRegexp matches operations that are allowed when suspended even though +// they cannot be classified as read-only via ServerOperationsMap policies. +// This covers two cases: +// - Empty-policy reads: operations mapped with {} in ServerOperationsMap that are actually reads +// - Self-service writes: operations the user needs to leave/delete even when suspended +var suspensionExemptRegexp = regexp.MustCompile(`controlplane.v1.CASCredentialsService/Get|controlplane.v1.UserService/(ListMemberships|SetCurrentMembership|DeleteMembership)|controlplane.v1.GroupService/(List|Get|ListMembers|ListProjects|ListPendingInvitations)|controlplane.v1.AuthService/DeleteAccount|controlplane.v1.OrganizationService/Delete$`) + +// WithSuspensionMiddleware blocks write operations when the current organization is suspended. +// +// Classification logic: +// 1. If the operation has policies in ServerOperationsMap with only read/list actions -> allow +// 2. If the operation matches suspensionExemptRegexp -> allow +// 3. Everything else (writes, empty policies, unmapped operations) -> block +func WithSuspensionMiddleware() middleware.Middleware { + return func(handler middleware.Handler) middleware.Handler { + return func(ctx context.Context, req interface{}) (interface{}, error) { + org := entities.CurrentOrg(ctx) + // No org context (e.g., user info, status endpoints), pass through + if org == nil { + return handler(ctx, req) + } + + if !org.Suspended { + return handler(ctx, req) + } + + // Org is suspended, determine if the operation is read-only + t, ok := transport.FromServerContext(ctx) + if !ok { + return nil, suspendedError() + } + + apiOperation := t.Operation() + + // Check exemptions first + if suspensionExemptRegexp.MatchString(apiOperation) { + return handler(ctx, req) + } + + // Look up policies in ServerOperationsMap + policies, err := authz.PoliciesLookup(apiOperation) + if err != nil { + // Operation not in ServerOperationsMap, block by default + return nil, suspendedError() + } + + // Empty policies, no action metadata to classify as read-only, block + if len(policies) == 0 { + return nil, suspendedError() + } + + // Allow only if ALL policy actions are read-only + for _, p := range policies { + if !readOnlyActions[p.Action] { + return nil, suspendedError() + } + } + + return handler(ctx, req) + } + } +} + +func suspendedError() error { + return errorsAPI.Forbidden("suspended", "organization is suspended, read-only access only") +} diff --git a/app/controlplane/internal/usercontext/suspension_middleware_test.go b/app/controlplane/internal/usercontext/suspension_middleware_test.go new file mode 100644 index 000000000..a84b9087f --- /dev/null +++ b/app/controlplane/internal/usercontext/suspension_middleware_test.go @@ -0,0 +1,218 @@ +package usercontext + +import ( + "context" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" + "github.com/go-kratos/kratos/v2/transport" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type suspensionMockTransport struct { + operation string +} + +func (tr *suspensionMockTransport) Kind() transport.Kind { return transport.KindGRPC } +func (tr *suspensionMockTransport) Endpoint() string { return "" } +func (tr *suspensionMockTransport) Operation() string { return tr.operation } +func (tr *suspensionMockTransport) RequestHeader() transport.Header { return nil } +func (tr *suspensionMockTransport) ReplyHeader() transport.Header { return nil } + +var passHandler = func(_ context.Context, _ interface{}) (interface{}, error) { return "ok", nil } + +func TestWithSuspensionMiddleware(t *testing.T) { + suspendedOrg := &entities.Org{ID: "org-1", Name: "test", Suspended: true} + activeOrg := &entities.Org{ID: "org-1", Name: "test", Suspended: false} + + tests := []struct { + name string + org *entities.Org + operation string + wantErr bool + wantMsg string + }{ + { + name: "no org context passes through", + org: nil, + wantErr: false, + }, + { + name: "non-suspended org passes through", + org: activeOrg, + operation: "/controlplane.v1.WorkflowService/Create", + wantErr: false, + }, + { + name: "suspended org allows read operation", + org: suspendedOrg, + operation: "/controlplane.v1.ReferrerService/DiscoverPrivate", + wantErr: false, + }, + { + name: "suspended org allows list operation", + org: suspendedOrg, + operation: "/controlplane.v1.WorkflowService/List", + wantErr: false, + }, + { + name: "suspended org allows read policy operation", + org: suspendedOrg, + operation: "/controlplane.v1.ContextService/Current", + wantErr: false, + }, + { + name: "suspended org allows exempt empty-policy read", + org: suspendedOrg, + operation: "/controlplane.v1.CASCredentialsService/Get", + wantErr: false, + }, + { + name: "suspended org allows exempt navigation operation", + org: suspendedOrg, + operation: "/controlplane.v1.UserService/ListMemberships", + wantErr: false, + }, + { + name: "suspended org allows exempt group read", + org: suspendedOrg, + operation: "/controlplane.v1.GroupService/ListMembers", + wantErr: false, + }, + { + name: "suspended org allows self-service org delete", + org: suspendedOrg, + operation: "/controlplane.v1.OrganizationService/Delete", + wantErr: false, + }, + { + name: "suspended org allows self-service leave org", + org: suspendedOrg, + operation: "/controlplane.v1.UserService/DeleteMembership", + wantErr: false, + }, + { + name: "suspended org allows self-service delete account", + org: suspendedOrg, + operation: "/controlplane.v1.AuthService/DeleteAccount", + wantErr: false, + }, + { + name: "suspended org blocks empty-policy write", + org: suspendedOrg, + operation: "/controlplane.v1.GroupService/AddMember", + wantErr: true, + wantMsg: "suspended", + }, + { + name: "suspended org blocks another empty-policy write", + org: suspendedOrg, + operation: "/controlplane.v1.GroupService/RemoveMember", + wantErr: true, + wantMsg: "suspended", + }, + { + name: "suspended org blocks empty-policy org create", + org: suspendedOrg, + operation: "/controlplane.v1.OrganizationService/Create", + wantErr: true, + wantMsg: "suspended", + }, + { + name: "suspended org blocks unmapped write", + org: suspendedOrg, + operation: "/controlplane.v1.CASBackendService/Update", + wantErr: true, + wantMsg: "suspended", + }, + { + name: "suspended org blocks another unmapped write", + org: suspendedOrg, + operation: "/controlplane.v1.OrgInvitationService/Revoke", + wantErr: true, + wantMsg: "suspended", + }, + { + name: "suspended org blocks attestation write", + org: suspendedOrg, + operation: "/controlplane.v1.AttestationService/Init", + wantErr: true, + wantMsg: "suspended", + }, + { + name: "suspended org blocks attestation state write", + org: suspendedOrg, + operation: "/controlplane.v1.AttestationStateService/Save", + wantErr: true, + wantMsg: "suspended", + }, + { + name: "suspended org blocks signing write", + org: suspendedOrg, + operation: "/controlplane.v1.SigningService/GenerateSigningCert", + wantErr: true, + wantMsg: "suspended", + }, + { + name: "suspended org blocks mapped write policy", + org: suspendedOrg, + operation: "/controlplane.v1.WorkflowService/Create", + wantErr: true, + wantMsg: "suspended", + }, + { + name: "suspended org blocks mapped update policy", + org: suspendedOrg, + operation: "/controlplane.v1.CASBackendService/Revalidate", + wantErr: true, + wantMsg: "suspended", + }, + { + name: "suspended org blocks OrganizationService/DeleteMembership despite Delete being exempt", + org: suspendedOrg, + operation: "/controlplane.v1.OrganizationService/DeleteMembership", + wantErr: true, + wantMsg: "suspended", + }, + { + name: "suspended org blocks unknown future endpoint", + org: suspendedOrg, + operation: "/controlplane.v1.NewService/SomeWrite", + wantErr: true, + wantMsg: "suspended", + }, + { + name: "suspended org with no transport context is blocked", + org: suspendedOrg, + wantErr: true, + wantMsg: "suspended", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + if tt.org != nil { + ctx = entities.WithCurrentOrg(ctx, tt.org) + } + + if tt.operation != "" { + ctx = transport.NewServerContext(ctx, &suspensionMockTransport{operation: tt.operation}) + } + + m := WithSuspensionMiddleware() + result, err := m(passHandler)(ctx, nil) + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantMsg) + assert.Nil(t, result) + } else { + require.NoError(t, err) + assert.Equal(t, "ok", result) + } + }) + } +} diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index 3a2adee97..95ce77b38 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -16,6 +16,11 @@ // Authorization package package authz +import ( + "errors" + "regexp" +) + // resource, action tuple type Policy struct { Resource string @@ -475,3 +480,28 @@ func (Role) Values() (roles []string) { return } + +// ErrOperationNotAllowed is returned when an operation has no entry in ServerOperationsMap. +var ErrOperationNotAllowed = errors.New("operation not allowed") + +// PoliciesLookup returns the policies required for a given API operation. +// It performs a two-pass lookup: +// 1. Direct match in ServerOperationsMap +// 2. Regex match for keys containing patterns (e.g., "/controlplane.v1.OrgMetricsService/.*") +func PoliciesLookup(apiOperation string) ([]*Policy, error) { + // Direct match + policies, found := ServerOperationsMap[apiOperation] + if found { + return policies, nil + } + + // Second pass: regex match + for k, policies := range ServerOperationsMap { + found, _ := regexp.MatchString(k, apiOperation) + if found { + return policies, nil + } + } + + return nil, ErrOperationNotAllowed +} diff --git a/app/controlplane/pkg/authz/middleware/middleware.go b/app/controlplane/pkg/authz/middleware/middleware.go index a55afb294..524788072 100644 --- a/app/controlplane/pkg/authz/middleware/middleware.go +++ b/app/controlplane/pkg/authz/middleware/middleware.go @@ -17,8 +17,6 @@ package middleware import ( "context" - "errors" - "regexp" errorsAPI "github.com/go-kratos/kratos/v2/errors" @@ -75,7 +73,7 @@ func WithAuthzMiddleware(enforcer Enforcer, logger *log.Helper) middleware.Middl func checkPolicies(ctx context.Context, subject, apiOperation string, enforcer Enforcer, logger *log.Helper) error { logger.Infow("msg", "[authZ] checking authorization", "sub", subject, "operation", apiOperation, "component", "authz/middleware") // If there is no entry in the map for this API operation, we deny access - policies, err := policiesLookup(apiOperation) + policies, err := authz.PoliciesLookup(apiOperation) if err != nil { return errorsAPI.Forbidden("forbidden", err.Error()) } @@ -97,25 +95,3 @@ func checkPolicies(ctx context.Context, subject, apiOperation string, enforcer E return nil } -// policiesLookup returns the policies required for a given API operation -// it performs a two run lookup -// 1 - It checks if there is an entry in the map -// 2 - if there is not, it runs a regex match in each key in case one of those keys contains a regex -func policiesLookup(apiOperation string) ([]*authz.Policy, error) { - // Direct match - policies, found := authz.ServerOperationsMap[apiOperation] - if found { - return policies, nil - } - - // second pass trying to match a regex - // i.e "/controlplane.v1.OrgMetricsService/.*" -> "/controlplane.v1.OrgMetricsService/Totals" - for k, policies := range authz.ServerOperationsMap { - found, _ := regexp.MatchString(k, apiOperation) - if found { - return policies, nil - } - } - - return nil, errors.New("operation not allowed") -} diff --git a/app/controlplane/pkg/authz/middleware/middleware_test.go b/app/controlplane/pkg/authz/middleware/middleware_test.go index 91b70ae18..36981ca89 100644 --- a/app/controlplane/pkg/authz/middleware/middleware_test.go +++ b/app/controlplane/pkg/authz/middleware/middleware_test.go @@ -214,7 +214,7 @@ func TestPoliciesLookup(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - _, err := policiesLookup(tc.operation) + _, err := authz.PoliciesLookup(tc.operation) if tc.wantErr { assert.Error(t, err) diff --git a/app/controlplane/pkg/authz/policies_lookup_test.go b/app/controlplane/pkg/authz/policies_lookup_test.go new file mode 100644 index 000000000..17c07fcd3 --- /dev/null +++ b/app/controlplane/pkg/authz/policies_lookup_test.go @@ -0,0 +1,92 @@ +package authz + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPoliciesLookup(t *testing.T) { + tests := []struct { + name string + operation string + wantErr bool + wantErrIs error + wantActionIn []string // at least one policy should have one of these actions + wantPolicyLen int // -1 means don't check + }{ + { + name: "direct match - read operation", + operation: "/controlplane.v1.ReferrerService/DiscoverPrivate", + wantPolicyLen: 1, + wantActionIn: []string{ActionRead}, + }, + { + name: "direct match - empty policies (open endpoint)", + operation: "/controlplane.v1.CASCredentialsService/Get", + wantPolicyLen: 0, + }, + { + name: "regex match - OrgMetricsService wildcard", + operation: "/controlplane.v1.OrgMetricsService/SomeMethod", + wantPolicyLen: 1, + wantActionIn: []string{ActionList}, + }, + { + name: "unknown operation returns error", + operation: "/controlplane.v1.NonExistentService/Unknown", + wantErr: true, + wantErrIs: ErrOperationNotAllowed, + }, + { + name: "empty operation returns error", + operation: "", + wantErr: true, + wantErrIs: ErrOperationNotAllowed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policies, err := PoliciesLookup(tt.operation) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + return + } + + require.NoError(t, err) + + if tt.wantPolicyLen >= 0 { + assert.Len(t, policies, tt.wantPolicyLen) + } + + if len(tt.wantActionIn) > 0 && len(policies) > 0 { + actions := make([]string, 0, len(policies)) + for _, p := range policies { + actions = append(actions, p.Action) + } + assert.Subset(t, tt.wantActionIn, actions) + } + }) + } +} + +func TestPoliciesLookupWriteOperation(t *testing.T) { + // WorkflowService/Create should return a policy with action "create" + policies, err := PoliciesLookup("/controlplane.v1.WorkflowService/Create") + require.NoError(t, err) + require.NotEmpty(t, policies) + + hasWriteAction := false + for _, p := range policies { + if p.Action == ActionCreate || p.Action == ActionUpdate || p.Action == ActionDelete { + hasWriteAction = true + break + } + } + assert.True(t, hasWriteAction, "WorkflowService/Create should have a write action policy") +} diff --git a/app/controlplane/pkg/biz/mocks/OrganizationRepo.go b/app/controlplane/pkg/biz/mocks/OrganizationRepo.go index 1f1f2b24e..44072ba4f 100644 --- a/app/controlplane/pkg/biz/mocks/OrganizationRepo.go +++ b/app/controlplane/pkg/biz/mocks/OrganizationRepo.go @@ -362,6 +362,69 @@ func (_c *OrganizationRepo_FindWithTokenInactivityThreshold_Call) RunAndReturn(r return _c } +// SetSuspended provides a mock function for the type OrganizationRepo +func (_mock *OrganizationRepo) SetSuspended(ctx context.Context, id uuid.UUID, suspended bool) error { + ret := _mock.Called(ctx, id, suspended) + + if len(ret) == 0 { + panic("no return value specified for SetSuspended") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID, bool) error); ok { + r0 = returnFunc(ctx, id, suspended) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// OrganizationRepo_SetSuspended_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetSuspended' +type OrganizationRepo_SetSuspended_Call struct { + *mock.Call +} + +// SetSuspended is a helper method to define mock.On call +// - ctx context.Context +// - id uuid.UUID +// - suspended bool +func (_e *OrganizationRepo_Expecter) SetSuspended(ctx interface{}, id interface{}, suspended interface{}) *OrganizationRepo_SetSuspended_Call { + return &OrganizationRepo_SetSuspended_Call{Call: _e.mock.On("SetSuspended", ctx, id, suspended)} +} + +func (_c *OrganizationRepo_SetSuspended_Call) Run(run func(ctx context.Context, id uuid.UUID, suspended bool)) *OrganizationRepo_SetSuspended_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 uuid.UUID + if args[1] != nil { + arg1 = args[1].(uuid.UUID) + } + var arg2 bool + if args[2] != nil { + arg2 = args[2].(bool) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *OrganizationRepo_SetSuspended_Call) Return(err error) *OrganizationRepo_SetSuspended_Call { + _c.Call.Return(err) + return _c +} + +func (_c *OrganizationRepo_SetSuspended_Call) RunAndReturn(run func(ctx context.Context, id uuid.UUID, suspended bool) error) *OrganizationRepo_SetSuspended_Call { + _c.Call.Return(run) + return _c +} + // Update provides a mock function for the type OrganizationRepo func (_mock *OrganizationRepo) Update(ctx context.Context, id uuid.UUID, opts *biz.OrganizationUpdateOpts) (*biz.Organization, error) { ret := _mock.Called(ctx, id, opts) diff --git a/app/controlplane/pkg/biz/organization.go b/app/controlplane/pkg/biz/organization.go index fb1a06cb9..bc7abbe3e 100644 --- a/app/controlplane/pkg/biz/organization.go +++ b/app/controlplane/pkg/biz/organization.go @@ -48,6 +48,8 @@ type Organization struct { APITokenInactivityThresholdDays *int // EnableAIAgentCollector enables automatic AI agent config collection during attestation init EnableAIAgentCollector bool + // Suspended indicates whether the organization is suspended (read-only mode) + Suspended bool } // OrganizationUpdateOpts holds optional fields for updating an organization. @@ -70,6 +72,7 @@ type OrganizationRepo interface { Delete(ctx context.Context, ID uuid.UUID) error // FindWithTokenInactivityThreshold returns orgs that have api_token_inactivity_threshold_days set (non-nil). FindWithTokenInactivityThreshold(ctx context.Context) ([]*Organization, error) + SetSuspended(ctx context.Context, id uuid.UUID, suspended bool) error } type OrganizationUseCase struct { diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/20260408122048.sql b/app/controlplane/pkg/data/ent/migrate/migrations/20260408122048.sql new file mode 100644 index 000000000..b9eb27eab --- /dev/null +++ b/app/controlplane/pkg/data/ent/migrate/migrations/20260408122048.sql @@ -0,0 +1,2 @@ +-- Modify "organizations" table +ALTER TABLE "organizations" ADD COLUMN "suspended" boolean NOT NULL DEFAULT false; diff --git a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum index 7a9b904e4..cb233c18f 100644 --- a/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum +++ b/app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:POeLVZ5wn0luHTIuCxBdUZBNVPEq0gfgfoaNFgQzR4g= +h1:/HATckRi5Q/sEHMczVjDJJ9VOwgJWiAp0Lpk8tmRVWk= 20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M= 20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g= 20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI= @@ -127,3 +127,4 @@ h1:POeLVZ5wn0luHTIuCxBdUZBNVPEq0gfgfoaNFgQzR4g= 20260211225609.sql h1:DTkyg3oZSV99uPGl+vOuK9FSlEumXwoYCgchUhsg/P4= 20260303120000.sql h1:msXy2MRkzMOGxWbG1NOHh+PN5qjaBZcRzVT+7SFIwaA= 20260318160301.sql h1:kH88s6pOi7Vprydb7xrzgY55JhMxfzY32txpQ8a1wEE= +20260408122048.sql h1:imfswpfmBlpP1l149/wCLN5HkN3/sGIQ3GnxaSnwOZE= diff --git a/app/controlplane/pkg/data/ent/migrate/schema.go b/app/controlplane/pkg/data/ent/migrate/schema.go index 3e59365a6..8da75ff6b 100644 --- a/app/controlplane/pkg/data/ent/migrate/schema.go +++ b/app/controlplane/pkg/data/ent/migrate/schema.go @@ -434,6 +434,7 @@ var ( {Name: "restrict_contract_creation_to_org_admins", Type: field.TypeBool, Default: false}, {Name: "api_token_inactivity_threshold_days", Type: field.TypeInt, Nullable: true}, {Name: "enable_ai_agent_collector", Type: field.TypeBool, Default: false}, + {Name: "suspended", Type: field.TypeBool, Default: false}, } // OrganizationsTable holds the schema information for the "organizations" table. OrganizationsTable = &schema.Table{ diff --git a/app/controlplane/pkg/data/ent/mutation.go b/app/controlplane/pkg/data/ent/mutation.go index 6bb19f4d0..8f9634a50 100644 --- a/app/controlplane/pkg/data/ent/mutation.go +++ b/app/controlplane/pkg/data/ent/mutation.go @@ -8749,6 +8749,7 @@ type OrganizationMutation struct { api_token_inactivity_threshold_days *int addapi_token_inactivity_threshold_days *int enable_ai_agent_collector *bool + suspended *bool clearedFields map[string]struct{} memberships map[uuid.UUID]struct{} removedmemberships map[uuid.UUID]struct{} @@ -9319,6 +9320,42 @@ func (m *OrganizationMutation) ResetEnableAiAgentCollector() { m.enable_ai_agent_collector = nil } +// SetSuspended sets the "suspended" field. +func (m *OrganizationMutation) SetSuspended(b bool) { + m.suspended = &b +} + +// Suspended returns the value of the "suspended" field in the mutation. +func (m *OrganizationMutation) Suspended() (r bool, exists bool) { + v := m.suspended + if v == nil { + return + } + return *v, true +} + +// OldSuspended returns the old "suspended" field's value of the Organization entity. +// If the Organization object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *OrganizationMutation) OldSuspended(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldSuspended is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldSuspended requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldSuspended: %w", err) + } + return oldValue.Suspended, nil +} + +// ResetSuspended resets all changes to the "suspended" field. +func (m *OrganizationMutation) ResetSuspended() { + m.suspended = nil +} + // AddMembershipIDs adds the "memberships" edge to the Membership entity by ids. func (m *OrganizationMutation) AddMembershipIDs(ids ...uuid.UUID) { if m.memberships == nil { @@ -9785,7 +9822,7 @@ func (m *OrganizationMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *OrganizationMutation) Fields() []string { - fields := make([]string, 0, 10) + fields := make([]string, 0, 11) if m.name != nil { fields = append(fields, organization.FieldName) } @@ -9816,6 +9853,9 @@ func (m *OrganizationMutation) Fields() []string { if m.enable_ai_agent_collector != nil { fields = append(fields, organization.FieldEnableAiAgentCollector) } + if m.suspended != nil { + fields = append(fields, organization.FieldSuspended) + } return fields } @@ -9844,6 +9884,8 @@ func (m *OrganizationMutation) Field(name string) (ent.Value, bool) { return m.APITokenInactivityThresholdDays() case organization.FieldEnableAiAgentCollector: return m.EnableAiAgentCollector() + case organization.FieldSuspended: + return m.Suspended() } return nil, false } @@ -9873,6 +9915,8 @@ func (m *OrganizationMutation) OldField(ctx context.Context, name string) (ent.V return m.OldAPITokenInactivityThresholdDays(ctx) case organization.FieldEnableAiAgentCollector: return m.OldEnableAiAgentCollector(ctx) + case organization.FieldSuspended: + return m.OldSuspended(ctx) } return nil, fmt.Errorf("unknown Organization field %s", name) } @@ -9952,6 +9996,13 @@ func (m *OrganizationMutation) SetField(name string, value ent.Value) error { } m.SetEnableAiAgentCollector(v) return nil + case organization.FieldSuspended: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetSuspended(v) + return nil } return fmt.Errorf("unknown Organization field %s", name) } @@ -10067,6 +10118,9 @@ func (m *OrganizationMutation) ResetField(name string) error { case organization.FieldEnableAiAgentCollector: m.ResetEnableAiAgentCollector() return nil + case organization.FieldSuspended: + m.ResetSuspended() + return nil } return fmt.Errorf("unknown Organization field %s", name) } diff --git a/app/controlplane/pkg/data/ent/organization.go b/app/controlplane/pkg/data/ent/organization.go index 83936fc97..d7007a95c 100644 --- a/app/controlplane/pkg/data/ent/organization.go +++ b/app/controlplane/pkg/data/ent/organization.go @@ -39,6 +39,8 @@ type Organization struct { APITokenInactivityThresholdDays *int `json:"api_token_inactivity_threshold_days,omitempty"` // EnableAiAgentCollector holds the value of the "enable_ai_agent_collector" field. EnableAiAgentCollector bool `json:"enable_ai_agent_collector,omitempty"` + // Suspended holds the value of the "suspended" field. + Suspended bool `json:"suspended,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the OrganizationQuery when eager-loading is set. Edges OrganizationEdges `json:"edges"` @@ -147,7 +149,7 @@ func (*Organization) scanValues(columns []string) ([]any, error) { switch columns[i] { case organization.FieldPoliciesAllowedHostnames: values[i] = new([]byte) - case organization.FieldBlockOnPolicyViolation, organization.FieldPreventImplicitWorkflowCreation, organization.FieldRestrictContractCreationToOrgAdmins, organization.FieldEnableAiAgentCollector: + case organization.FieldBlockOnPolicyViolation, organization.FieldPreventImplicitWorkflowCreation, organization.FieldRestrictContractCreationToOrgAdmins, organization.FieldEnableAiAgentCollector, organization.FieldSuspended: values[i] = new(sql.NullBool) case organization.FieldAPITokenInactivityThresholdDays: values[i] = new(sql.NullInt64) @@ -241,6 +243,12 @@ func (_m *Organization) assignValues(columns []string, values []any) error { } else if value.Valid { _m.EnableAiAgentCollector = value.Bool } + case organization.FieldSuspended: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field suspended", values[i]) + } else if value.Valid { + _m.Suspended = value.Bool + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -348,6 +356,9 @@ func (_m *Organization) String() string { builder.WriteString(", ") builder.WriteString("enable_ai_agent_collector=") builder.WriteString(fmt.Sprintf("%v", _m.EnableAiAgentCollector)) + builder.WriteString(", ") + builder.WriteString("suspended=") + builder.WriteString(fmt.Sprintf("%v", _m.Suspended)) builder.WriteByte(')') return builder.String() } diff --git a/app/controlplane/pkg/data/ent/organization/organization.go b/app/controlplane/pkg/data/ent/organization/organization.go index a51c4bd11..310bf1a49 100644 --- a/app/controlplane/pkg/data/ent/organization/organization.go +++ b/app/controlplane/pkg/data/ent/organization/organization.go @@ -35,6 +35,8 @@ const ( FieldAPITokenInactivityThresholdDays = "api_token_inactivity_threshold_days" // FieldEnableAiAgentCollector holds the string denoting the enable_ai_agent_collector field in the database. FieldEnableAiAgentCollector = "enable_ai_agent_collector" + // FieldSuspended holds the string denoting the suspended field in the database. + FieldSuspended = "suspended" // EdgeMemberships holds the string denoting the memberships edge name in mutations. EdgeMemberships = "memberships" // EdgeWorkflowContracts holds the string denoting the workflow_contracts edge name in mutations. @@ -124,6 +126,7 @@ var Columns = []string{ FieldRestrictContractCreationToOrgAdmins, FieldAPITokenInactivityThresholdDays, FieldEnableAiAgentCollector, + FieldSuspended, } // ValidColumn reports if the column name is valid (part of the table columns). @@ -149,6 +152,8 @@ var ( DefaultRestrictContractCreationToOrgAdmins bool // DefaultEnableAiAgentCollector holds the default value on creation for the "enable_ai_agent_collector" field. DefaultEnableAiAgentCollector bool + // DefaultSuspended holds the default value on creation for the "suspended" field. + DefaultSuspended bool // DefaultID holds the default value on creation for the "id" field. DefaultID func() uuid.UUID ) @@ -206,6 +211,11 @@ func ByEnableAiAgentCollector(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldEnableAiAgentCollector, opts...).ToFunc() } +// BySuspended orders the results by the suspended field. +func BySuspended(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldSuspended, opts...).ToFunc() +} + // ByMembershipsCount orders the results by memberships count. func ByMembershipsCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { diff --git a/app/controlplane/pkg/data/ent/organization/where.go b/app/controlplane/pkg/data/ent/organization/where.go index 36a1abb09..c61ac829f 100644 --- a/app/controlplane/pkg/data/ent/organization/where.go +++ b/app/controlplane/pkg/data/ent/organization/where.go @@ -101,6 +101,11 @@ func EnableAiAgentCollector(v bool) predicate.Organization { return predicate.Organization(sql.FieldEQ(FieldEnableAiAgentCollector, v)) } +// Suspended applies equality check predicate on the "suspended" field. It's identical to SuspendedEQ. +func Suspended(v bool) predicate.Organization { + return predicate.Organization(sql.FieldEQ(FieldSuspended, v)) +} + // NameEQ applies the EQ predicate on the "name" field. func NameEQ(v string) predicate.Organization { return predicate.Organization(sql.FieldEQ(FieldName, v)) @@ -396,6 +401,16 @@ func EnableAiAgentCollectorNEQ(v bool) predicate.Organization { return predicate.Organization(sql.FieldNEQ(FieldEnableAiAgentCollector, v)) } +// SuspendedEQ applies the EQ predicate on the "suspended" field. +func SuspendedEQ(v bool) predicate.Organization { + return predicate.Organization(sql.FieldEQ(FieldSuspended, v)) +} + +// SuspendedNEQ applies the NEQ predicate on the "suspended" field. +func SuspendedNEQ(v bool) predicate.Organization { + return predicate.Organization(sql.FieldNEQ(FieldSuspended, v)) +} + // HasMemberships applies the HasEdge predicate on the "memberships" edge. func HasMemberships() predicate.Organization { return predicate.Organization(func(s *sql.Selector) { diff --git a/app/controlplane/pkg/data/ent/organization_create.go b/app/controlplane/pkg/data/ent/organization_create.go index bbe8ca725..b6770887b 100644 --- a/app/controlplane/pkg/data/ent/organization_create.go +++ b/app/controlplane/pkg/data/ent/organization_create.go @@ -156,6 +156,20 @@ func (_c *OrganizationCreate) SetNillableEnableAiAgentCollector(v *bool) *Organi return _c } +// SetSuspended sets the "suspended" field. +func (_c *OrganizationCreate) SetSuspended(v bool) *OrganizationCreate { + _c.mutation.SetSuspended(v) + return _c +} + +// SetNillableSuspended sets the "suspended" field if the given value is not nil. +func (_c *OrganizationCreate) SetNillableSuspended(v *bool) *OrganizationCreate { + if v != nil { + _c.SetSuspended(*v) + } + return _c +} + // SetID sets the "id" field. func (_c *OrganizationCreate) SetID(v uuid.UUID) *OrganizationCreate { _c.mutation.SetID(v) @@ -349,6 +363,10 @@ func (_c *OrganizationCreate) defaults() { v := organization.DefaultEnableAiAgentCollector _c.mutation.SetEnableAiAgentCollector(v) } + if _, ok := _c.mutation.Suspended(); !ok { + v := organization.DefaultSuspended + _c.mutation.SetSuspended(v) + } if _, ok := _c.mutation.ID(); !ok { v := organization.DefaultID() _c.mutation.SetID(v) @@ -378,6 +396,9 @@ func (_c *OrganizationCreate) check() error { if _, ok := _c.mutation.EnableAiAgentCollector(); !ok { return &ValidationError{Name: "enable_ai_agent_collector", err: errors.New(`ent: missing required field "Organization.enable_ai_agent_collector"`)} } + if _, ok := _c.mutation.Suspended(); !ok { + return &ValidationError{Name: "suspended", err: errors.New(`ent: missing required field "Organization.suspended"`)} + } return nil } @@ -454,6 +475,10 @@ func (_c *OrganizationCreate) createSpec() (*Organization, *sqlgraph.CreateSpec) _spec.SetField(organization.FieldEnableAiAgentCollector, field.TypeBool, value) _node.EnableAiAgentCollector = value } + if value, ok := _c.mutation.Suspended(); ok { + _spec.SetField(organization.FieldSuspended, field.TypeBool, value) + _node.Suspended = value + } if nodes := _c.mutation.MembershipsIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -766,6 +791,18 @@ func (u *OrganizationUpsert) UpdateEnableAiAgentCollector() *OrganizationUpsert return u } +// SetSuspended sets the "suspended" field. +func (u *OrganizationUpsert) SetSuspended(v bool) *OrganizationUpsert { + u.Set(organization.FieldSuspended, v) + return u +} + +// UpdateSuspended sets the "suspended" field to the value that was provided on create. +func (u *OrganizationUpsert) UpdateSuspended() *OrganizationUpsert { + u.SetExcluded(organization.FieldSuspended) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create except the ID field. // Using this option is equivalent to using: // @@ -971,6 +1008,20 @@ func (u *OrganizationUpsertOne) UpdateEnableAiAgentCollector() *OrganizationUpse }) } +// SetSuspended sets the "suspended" field. +func (u *OrganizationUpsertOne) SetSuspended(v bool) *OrganizationUpsertOne { + return u.Update(func(s *OrganizationUpsert) { + s.SetSuspended(v) + }) +} + +// UpdateSuspended sets the "suspended" field to the value that was provided on create. +func (u *OrganizationUpsertOne) UpdateSuspended() *OrganizationUpsertOne { + return u.Update(func(s *OrganizationUpsert) { + s.UpdateSuspended() + }) +} + // Exec executes the query. func (u *OrganizationUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -1343,6 +1394,20 @@ func (u *OrganizationUpsertBulk) UpdateEnableAiAgentCollector() *OrganizationUps }) } +// SetSuspended sets the "suspended" field. +func (u *OrganizationUpsertBulk) SetSuspended(v bool) *OrganizationUpsertBulk { + return u.Update(func(s *OrganizationUpsert) { + s.SetSuspended(v) + }) +} + +// UpdateSuspended sets the "suspended" field to the value that was provided on create. +func (u *OrganizationUpsertBulk) UpdateSuspended() *OrganizationUpsertBulk { + return u.Update(func(s *OrganizationUpsert) { + s.UpdateSuspended() + }) +} + // Exec executes the query. func (u *OrganizationUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/app/controlplane/pkg/data/ent/organization_update.go b/app/controlplane/pkg/data/ent/organization_update.go index 39d0b51c6..44fc3b02b 100644 --- a/app/controlplane/pkg/data/ent/organization_update.go +++ b/app/controlplane/pkg/data/ent/organization_update.go @@ -188,6 +188,20 @@ func (_u *OrganizationUpdate) SetNillableEnableAiAgentCollector(v *bool) *Organi return _u } +// SetSuspended sets the "suspended" field. +func (_u *OrganizationUpdate) SetSuspended(v bool) *OrganizationUpdate { + _u.mutation.SetSuspended(v) + return _u +} + +// SetNillableSuspended sets the "suspended" field if the given value is not nil. +func (_u *OrganizationUpdate) SetNillableSuspended(v *bool) *OrganizationUpdate { + if v != nil { + _u.SetSuspended(*v) + } + return _u +} + // AddMembershipIDs adds the "memberships" edge to the Membership entity by IDs. func (_u *OrganizationUpdate) AddMembershipIDs(ids ...uuid.UUID) *OrganizationUpdate { _u.mutation.AddMembershipIDs(ids...) @@ -567,6 +581,9 @@ func (_u *OrganizationUpdate) sqlSave(ctx context.Context) (_node int, err error if value, ok := _u.mutation.EnableAiAgentCollector(); ok { _spec.SetField(organization.FieldEnableAiAgentCollector, field.TypeBool, value) } + if value, ok := _u.mutation.Suspended(); ok { + _spec.SetField(organization.FieldSuspended, field.TypeBool, value) + } if _u.mutation.MembershipsCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -1098,6 +1115,20 @@ func (_u *OrganizationUpdateOne) SetNillableEnableAiAgentCollector(v *bool) *Org return _u } +// SetSuspended sets the "suspended" field. +func (_u *OrganizationUpdateOne) SetSuspended(v bool) *OrganizationUpdateOne { + _u.mutation.SetSuspended(v) + return _u +} + +// SetNillableSuspended sets the "suspended" field if the given value is not nil. +func (_u *OrganizationUpdateOne) SetNillableSuspended(v *bool) *OrganizationUpdateOne { + if v != nil { + _u.SetSuspended(*v) + } + return _u +} + // AddMembershipIDs adds the "memberships" edge to the Membership entity by IDs. func (_u *OrganizationUpdateOne) AddMembershipIDs(ids ...uuid.UUID) *OrganizationUpdateOne { _u.mutation.AddMembershipIDs(ids...) @@ -1507,6 +1538,9 @@ func (_u *OrganizationUpdateOne) sqlSave(ctx context.Context) (_node *Organizati if value, ok := _u.mutation.EnableAiAgentCollector(); ok { _spec.SetField(organization.FieldEnableAiAgentCollector, field.TypeBool, value) } + if value, ok := _u.mutation.Suspended(); ok { + _spec.SetField(organization.FieldSuspended, field.TypeBool, value) + } if _u.mutation.MembershipsCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/app/controlplane/pkg/data/ent/runtime.go b/app/controlplane/pkg/data/ent/runtime.go index adb88e48d..f02157bc3 100644 --- a/app/controlplane/pkg/data/ent/runtime.go +++ b/app/controlplane/pkg/data/ent/runtime.go @@ -217,6 +217,10 @@ func init() { organizationDescEnableAiAgentCollector := organizationFields[10].Descriptor() // organization.DefaultEnableAiAgentCollector holds the default value on creation for the enable_ai_agent_collector field. organization.DefaultEnableAiAgentCollector = organizationDescEnableAiAgentCollector.Default.(bool) + // organizationDescSuspended is the schema descriptor for suspended field. + organizationDescSuspended := organizationFields[11].Descriptor() + // organization.DefaultSuspended holds the default value on creation for the suspended field. + organization.DefaultSuspended = organizationDescSuspended.Default.(bool) // organizationDescID is the schema descriptor for id field. organizationDescID := organizationFields[0].Descriptor() // organization.DefaultID holds the default value on creation for the id field. diff --git a/app/controlplane/pkg/data/ent/schema/organization.go b/app/controlplane/pkg/data/ent/schema/organization.go index f360eb015..f0917f478 100644 --- a/app/controlplane/pkg/data/ent/schema/organization.go +++ b/app/controlplane/pkg/data/ent/schema/organization.go @@ -60,6 +60,8 @@ func (Organization) Fields() []ent.Field { field.Int("api_token_inactivity_threshold_days").Optional().Nillable(), // enable_ai_agent_collector enables automatic AI agent config collection during attestation init field.Bool("enable_ai_agent_collector").Default(false), + // Suspended orgs are restricted to read-only operations. + field.Bool("suspended").Default(false), } } diff --git a/app/controlplane/pkg/data/organization.go b/app/controlplane/pkg/data/organization.go index b47671023..cc9f4af07 100644 --- a/app/controlplane/pkg/data/organization.go +++ b/app/controlplane/pkg/data/organization.go @@ -138,6 +138,15 @@ func (r *OrganizationRepo) Delete(ctx context.Context, id uuid.UUID) error { Exec(ctx) } +// SetSuspended sets or clears the suspended flag on an organization. +func (r *OrganizationRepo) SetSuspended(ctx context.Context, id uuid.UUID, suspended bool) error { + return r.data.DB.Organization.UpdateOneID(id). + Where(organization.DeletedAtIsNil()). + SetSuspended(suspended). + SetUpdatedAt(time.Now()). + Exec(ctx) +} + func entOrgToBizOrg(eu *ent.Organization) *biz.Organization { return &biz.Organization{ Name: eu.Name, ID: eu.ID.String(), @@ -149,5 +158,6 @@ func entOrgToBizOrg(eu *ent.Organization) *biz.Organization { RestrictContractCreationToOrgAdmins: eu.RestrictContractCreationToOrgAdmins, APITokenInactivityThresholdDays: eu.APITokenInactivityThresholdDays, EnableAIAgentCollector: eu.EnableAiAgentCollector, + Suspended: eu.Suspended, } } From fdc5cd547a39fd59f879e8aec50a50c18c625e88 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Wed, 8 Apr 2026 15:10:06 +0200 Subject: [PATCH 2/4] lint Signed-off-by: Sylwester Piskozub --- .../usercontext/suspension_middleware.go | 15 +++++++++++++ .../usercontext/suspension_middleware_test.go | 15 +++++++++++++ .../pkg/authz/policies_lookup_test.go | 21 ++++++++++++++++--- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/app/controlplane/internal/usercontext/suspension_middleware.go b/app/controlplane/internal/usercontext/suspension_middleware.go index 5a52a134a..7b568cccd 100644 --- a/app/controlplane/internal/usercontext/suspension_middleware.go +++ b/app/controlplane/internal/usercontext/suspension_middleware.go @@ -1,3 +1,18 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package usercontext import ( diff --git a/app/controlplane/internal/usercontext/suspension_middleware_test.go b/app/controlplane/internal/usercontext/suspension_middleware_test.go index a84b9087f..263417814 100644 --- a/app/controlplane/internal/usercontext/suspension_middleware_test.go +++ b/app/controlplane/internal/usercontext/suspension_middleware_test.go @@ -1,3 +1,18 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package usercontext import ( diff --git a/app/controlplane/pkg/authz/policies_lookup_test.go b/app/controlplane/pkg/authz/policies_lookup_test.go index 17c07fcd3..f451c1293 100644 --- a/app/controlplane/pkg/authz/policies_lookup_test.go +++ b/app/controlplane/pkg/authz/policies_lookup_test.go @@ -1,3 +1,18 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package authz import ( @@ -17,10 +32,10 @@ func TestPoliciesLookup(t *testing.T) { wantPolicyLen int // -1 means don't check }{ { - name: "direct match - read operation", - operation: "/controlplane.v1.ReferrerService/DiscoverPrivate", + name: "direct match - read operation", + operation: "/controlplane.v1.ReferrerService/DiscoverPrivate", wantPolicyLen: 1, - wantActionIn: []string{ActionRead}, + wantActionIn: []string{ActionRead}, }, { name: "direct match - empty policies (open endpoint)", From 8ffe0b8b88fd4b3c73842934c0d2ad650868fd40 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Thu, 9 Apr 2026 11:22:56 +0200 Subject: [PATCH 3/4] block all requests Signed-off-by: Sylwester Piskozub --- .../usercontext/suspension_middleware.go | 65 +------ .../usercontext/suspension_middleware_test.go | 174 +----------------- app/controlplane/pkg/authz/authz.go | 30 --- .../pkg/authz/middleware/middleware.go | 26 ++- .../pkg/authz/middleware/middleware_test.go | 2 +- .../pkg/authz/policies_lookup_test.go | 107 ----------- 6 files changed, 38 insertions(+), 366 deletions(-) delete mode 100644 app/controlplane/pkg/authz/policies_lookup_test.go diff --git a/app/controlplane/internal/usercontext/suspension_middleware.go b/app/controlplane/internal/usercontext/suspension_middleware.go index 7b568cccd..268453a50 100644 --- a/app/controlplane/internal/usercontext/suspension_middleware.go +++ b/app/controlplane/internal/usercontext/suspension_middleware.go @@ -17,84 +17,27 @@ package usercontext import ( "context" - "regexp" "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" - "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" errorsAPI "github.com/go-kratos/kratos/v2/errors" "github.com/go-kratos/kratos/v2/middleware" - "github.com/go-kratos/kratos/v2/transport" ) -// readOnlyActions are policy actions considered safe during suspension. -var readOnlyActions = map[string]bool{ - authz.ActionRead: true, - authz.ActionList: true, -} - -// suspensionExemptRegexp matches operations that are allowed when suspended even though -// they cannot be classified as read-only via ServerOperationsMap policies. -// This covers two cases: -// - Empty-policy reads: operations mapped with {} in ServerOperationsMap that are actually reads -// - Self-service writes: operations the user needs to leave/delete even when suspended -var suspensionExemptRegexp = regexp.MustCompile(`controlplane.v1.CASCredentialsService/Get|controlplane.v1.UserService/(ListMemberships|SetCurrentMembership|DeleteMembership)|controlplane.v1.GroupService/(List|Get|ListMembers|ListProjects|ListPendingInvitations)|controlplane.v1.AuthService/DeleteAccount|controlplane.v1.OrganizationService/Delete$`) - -// WithSuspensionMiddleware blocks write operations when the current organization is suspended. -// -// Classification logic: -// 1. If the operation has policies in ServerOperationsMap with only read/list actions -> allow -// 2. If the operation matches suspensionExemptRegexp -> allow -// 3. Everything else (writes, empty policies, unmapped operations) -> block +// WithSuspensionMiddleware blocks all requests when the current organization is suspended. +// If there is no org in context (e.g. status endpoints), the request passes through. func WithSuspensionMiddleware() middleware.Middleware { return func(handler middleware.Handler) middleware.Handler { return func(ctx context.Context, req interface{}) (interface{}, error) { org := entities.CurrentOrg(ctx) - // No org context (e.g., user info, status endpoints), pass through if org == nil { return handler(ctx, req) } - if !org.Suspended { - return handler(ctx, req) - } - - // Org is suspended, determine if the operation is read-only - t, ok := transport.FromServerContext(ctx) - if !ok { - return nil, suspendedError() - } - - apiOperation := t.Operation() - - // Check exemptions first - if suspensionExemptRegexp.MatchString(apiOperation) { - return handler(ctx, req) - } - - // Look up policies in ServerOperationsMap - policies, err := authz.PoliciesLookup(apiOperation) - if err != nil { - // Operation not in ServerOperationsMap, block by default - return nil, suspendedError() - } - - // Empty policies, no action metadata to classify as read-only, block - if len(policies) == 0 { - return nil, suspendedError() - } - - // Allow only if ALL policy actions are read-only - for _, p := range policies { - if !readOnlyActions[p.Action] { - return nil, suspendedError() - } + if org.Suspended { + return nil, errorsAPI.Forbidden("suspended", "organization is suspended") } return handler(ctx, req) } } } - -func suspendedError() error { - return errorsAPI.Forbidden("suspended", "organization is suspended, read-only access only") -} diff --git a/app/controlplane/internal/usercontext/suspension_middleware_test.go b/app/controlplane/internal/usercontext/suspension_middleware_test.go index 263417814..4680c4194 100644 --- a/app/controlplane/internal/usercontext/suspension_middleware_test.go +++ b/app/controlplane/internal/usercontext/suspension_middleware_test.go @@ -20,21 +20,10 @@ import ( "testing" "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" - "github.com/go-kratos/kratos/v2/transport" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -type suspensionMockTransport struct { - operation string -} - -func (tr *suspensionMockTransport) Kind() transport.Kind { return transport.KindGRPC } -func (tr *suspensionMockTransport) Endpoint() string { return "" } -func (tr *suspensionMockTransport) Operation() string { return tr.operation } -func (tr *suspensionMockTransport) RequestHeader() transport.Header { return nil } -func (tr *suspensionMockTransport) ReplyHeader() transport.Header { return nil } - var passHandler = func(_ context.Context, _ interface{}) (interface{}, error) { return "ok", nil } func TestWithSuspensionMiddleware(t *testing.T) { @@ -42,11 +31,9 @@ func TestWithSuspensionMiddleware(t *testing.T) { activeOrg := &entities.Org{ID: "org-1", Name: "test", Suspended: false} tests := []struct { - name string - org *entities.Org - operation string - wantErr bool - wantMsg string + name string + org *entities.Org + wantErr bool }{ { name: "no org context passes through", @@ -54,175 +41,30 @@ func TestWithSuspensionMiddleware(t *testing.T) { wantErr: false, }, { - name: "non-suspended org passes through", - org: activeOrg, - operation: "/controlplane.v1.WorkflowService/Create", - wantErr: false, - }, - { - name: "suspended org allows read operation", - org: suspendedOrg, - operation: "/controlplane.v1.ReferrerService/DiscoverPrivate", - wantErr: false, - }, - { - name: "suspended org allows list operation", - org: suspendedOrg, - operation: "/controlplane.v1.WorkflowService/List", - wantErr: false, - }, - { - name: "suspended org allows read policy operation", - org: suspendedOrg, - operation: "/controlplane.v1.ContextService/Current", - wantErr: false, - }, - { - name: "suspended org allows exempt empty-policy read", - org: suspendedOrg, - operation: "/controlplane.v1.CASCredentialsService/Get", - wantErr: false, - }, - { - name: "suspended org allows exempt navigation operation", - org: suspendedOrg, - operation: "/controlplane.v1.UserService/ListMemberships", - wantErr: false, - }, - { - name: "suspended org allows exempt group read", - org: suspendedOrg, - operation: "/controlplane.v1.GroupService/ListMembers", - wantErr: false, - }, - { - name: "suspended org allows self-service org delete", - org: suspendedOrg, - operation: "/controlplane.v1.OrganizationService/Delete", - wantErr: false, - }, - { - name: "suspended org allows self-service leave org", - org: suspendedOrg, - operation: "/controlplane.v1.UserService/DeleteMembership", - wantErr: false, - }, - { - name: "suspended org allows self-service delete account", - org: suspendedOrg, - operation: "/controlplane.v1.AuthService/DeleteAccount", - wantErr: false, - }, - { - name: "suspended org blocks empty-policy write", - org: suspendedOrg, - operation: "/controlplane.v1.GroupService/AddMember", - wantErr: true, - wantMsg: "suspended", - }, - { - name: "suspended org blocks another empty-policy write", - org: suspendedOrg, - operation: "/controlplane.v1.GroupService/RemoveMember", - wantErr: true, - wantMsg: "suspended", - }, - { - name: "suspended org blocks empty-policy org create", - org: suspendedOrg, - operation: "/controlplane.v1.OrganizationService/Create", - wantErr: true, - wantMsg: "suspended", - }, - { - name: "suspended org blocks unmapped write", - org: suspendedOrg, - operation: "/controlplane.v1.CASBackendService/Update", - wantErr: true, - wantMsg: "suspended", - }, - { - name: "suspended org blocks another unmapped write", - org: suspendedOrg, - operation: "/controlplane.v1.OrgInvitationService/Revoke", - wantErr: true, - wantMsg: "suspended", - }, - { - name: "suspended org blocks attestation write", - org: suspendedOrg, - operation: "/controlplane.v1.AttestationService/Init", - wantErr: true, - wantMsg: "suspended", - }, - { - name: "suspended org blocks attestation state write", - org: suspendedOrg, - operation: "/controlplane.v1.AttestationStateService/Save", - wantErr: true, - wantMsg: "suspended", - }, - { - name: "suspended org blocks signing write", - org: suspendedOrg, - operation: "/controlplane.v1.SigningService/GenerateSigningCert", - wantErr: true, - wantMsg: "suspended", - }, - { - name: "suspended org blocks mapped write policy", - org: suspendedOrg, - operation: "/controlplane.v1.WorkflowService/Create", - wantErr: true, - wantMsg: "suspended", - }, - { - name: "suspended org blocks mapped update policy", - org: suspendedOrg, - operation: "/controlplane.v1.CASBackendService/Revalidate", - wantErr: true, - wantMsg: "suspended", - }, - { - name: "suspended org blocks OrganizationService/DeleteMembership despite Delete being exempt", - org: suspendedOrg, - operation: "/controlplane.v1.OrganizationService/DeleteMembership", - wantErr: true, - wantMsg: "suspended", - }, - { - name: "suspended org blocks unknown future endpoint", - org: suspendedOrg, - operation: "/controlplane.v1.NewService/SomeWrite", - wantErr: true, - wantMsg: "suspended", + name: "active org passes through", + org: activeOrg, + wantErr: false, }, { - name: "suspended org with no transport context is blocked", + name: "suspended org is blocked", org: suspendedOrg, wantErr: true, - wantMsg: "suspended", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() - if tt.org != nil { ctx = entities.WithCurrentOrg(ctx, tt.org) } - if tt.operation != "" { - ctx = transport.NewServerContext(ctx, &suspensionMockTransport{operation: tt.operation}) - } - m := WithSuspensionMiddleware() result, err := m(passHandler)(ctx, nil) if tt.wantErr { require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantMsg) + assert.Contains(t, err.Error(), "suspended") assert.Nil(t, result) } else { require.NoError(t, err) diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index 95ce77b38..3a2adee97 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -16,11 +16,6 @@ // Authorization package package authz -import ( - "errors" - "regexp" -) - // resource, action tuple type Policy struct { Resource string @@ -480,28 +475,3 @@ func (Role) Values() (roles []string) { return } - -// ErrOperationNotAllowed is returned when an operation has no entry in ServerOperationsMap. -var ErrOperationNotAllowed = errors.New("operation not allowed") - -// PoliciesLookup returns the policies required for a given API operation. -// It performs a two-pass lookup: -// 1. Direct match in ServerOperationsMap -// 2. Regex match for keys containing patterns (e.g., "/controlplane.v1.OrgMetricsService/.*") -func PoliciesLookup(apiOperation string) ([]*Policy, error) { - // Direct match - policies, found := ServerOperationsMap[apiOperation] - if found { - return policies, nil - } - - // Second pass: regex match - for k, policies := range ServerOperationsMap { - found, _ := regexp.MatchString(k, apiOperation) - if found { - return policies, nil - } - } - - return nil, ErrOperationNotAllowed -} diff --git a/app/controlplane/pkg/authz/middleware/middleware.go b/app/controlplane/pkg/authz/middleware/middleware.go index 524788072..a55afb294 100644 --- a/app/controlplane/pkg/authz/middleware/middleware.go +++ b/app/controlplane/pkg/authz/middleware/middleware.go @@ -17,6 +17,8 @@ package middleware import ( "context" + "errors" + "regexp" errorsAPI "github.com/go-kratos/kratos/v2/errors" @@ -73,7 +75,7 @@ func WithAuthzMiddleware(enforcer Enforcer, logger *log.Helper) middleware.Middl func checkPolicies(ctx context.Context, subject, apiOperation string, enforcer Enforcer, logger *log.Helper) error { logger.Infow("msg", "[authZ] checking authorization", "sub", subject, "operation", apiOperation, "component", "authz/middleware") // If there is no entry in the map for this API operation, we deny access - policies, err := authz.PoliciesLookup(apiOperation) + policies, err := policiesLookup(apiOperation) if err != nil { return errorsAPI.Forbidden("forbidden", err.Error()) } @@ -95,3 +97,25 @@ func checkPolicies(ctx context.Context, subject, apiOperation string, enforcer E return nil } +// policiesLookup returns the policies required for a given API operation +// it performs a two run lookup +// 1 - It checks if there is an entry in the map +// 2 - if there is not, it runs a regex match in each key in case one of those keys contains a regex +func policiesLookup(apiOperation string) ([]*authz.Policy, error) { + // Direct match + policies, found := authz.ServerOperationsMap[apiOperation] + if found { + return policies, nil + } + + // second pass trying to match a regex + // i.e "/controlplane.v1.OrgMetricsService/.*" -> "/controlplane.v1.OrgMetricsService/Totals" + for k, policies := range authz.ServerOperationsMap { + found, _ := regexp.MatchString(k, apiOperation) + if found { + return policies, nil + } + } + + return nil, errors.New("operation not allowed") +} diff --git a/app/controlplane/pkg/authz/middleware/middleware_test.go b/app/controlplane/pkg/authz/middleware/middleware_test.go index 36981ca89..91b70ae18 100644 --- a/app/controlplane/pkg/authz/middleware/middleware_test.go +++ b/app/controlplane/pkg/authz/middleware/middleware_test.go @@ -214,7 +214,7 @@ func TestPoliciesLookup(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - _, err := authz.PoliciesLookup(tc.operation) + _, err := policiesLookup(tc.operation) if tc.wantErr { assert.Error(t, err) diff --git a/app/controlplane/pkg/authz/policies_lookup_test.go b/app/controlplane/pkg/authz/policies_lookup_test.go deleted file mode 100644 index f451c1293..000000000 --- a/app/controlplane/pkg/authz/policies_lookup_test.go +++ /dev/null @@ -1,107 +0,0 @@ -// -// Copyright 2026 The Chainloop Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package authz - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestPoliciesLookup(t *testing.T) { - tests := []struct { - name string - operation string - wantErr bool - wantErrIs error - wantActionIn []string // at least one policy should have one of these actions - wantPolicyLen int // -1 means don't check - }{ - { - name: "direct match - read operation", - operation: "/controlplane.v1.ReferrerService/DiscoverPrivate", - wantPolicyLen: 1, - wantActionIn: []string{ActionRead}, - }, - { - name: "direct match - empty policies (open endpoint)", - operation: "/controlplane.v1.CASCredentialsService/Get", - wantPolicyLen: 0, - }, - { - name: "regex match - OrgMetricsService wildcard", - operation: "/controlplane.v1.OrgMetricsService/SomeMethod", - wantPolicyLen: 1, - wantActionIn: []string{ActionList}, - }, - { - name: "unknown operation returns error", - operation: "/controlplane.v1.NonExistentService/Unknown", - wantErr: true, - wantErrIs: ErrOperationNotAllowed, - }, - { - name: "empty operation returns error", - operation: "", - wantErr: true, - wantErrIs: ErrOperationNotAllowed, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - policies, err := PoliciesLookup(tt.operation) - if tt.wantErr { - require.Error(t, err) - if tt.wantErrIs != nil { - assert.ErrorIs(t, err, tt.wantErrIs) - } - return - } - - require.NoError(t, err) - - if tt.wantPolicyLen >= 0 { - assert.Len(t, policies, tt.wantPolicyLen) - } - - if len(tt.wantActionIn) > 0 && len(policies) > 0 { - actions := make([]string, 0, len(policies)) - for _, p := range policies { - actions = append(actions, p.Action) - } - assert.Subset(t, tt.wantActionIn, actions) - } - }) - } -} - -func TestPoliciesLookupWriteOperation(t *testing.T) { - // WorkflowService/Create should return a policy with action "create" - policies, err := PoliciesLookup("/controlplane.v1.WorkflowService/Create") - require.NoError(t, err) - require.NotEmpty(t, policies) - - hasWriteAction := false - for _, p := range policies { - if p.Action == ActionCreate || p.Action == ActionUpdate || p.Action == ActionDelete { - hasWriteAction = true - break - } - } - assert.True(t, hasWriteAction, "WorkflowService/Create should have a write action policy") -} From d206c3268fd903625b6ace22d9bc761297ecd6f5 Mon Sep 17 00:00:00 2001 From: Sylwester Piskozub Date: Thu, 9 Apr 2026 11:32:18 +0200 Subject: [PATCH 4/4] update comments Signed-off-by: Sylwester Piskozub --- app/controlplane/internal/server/grpc.go | 4 ++-- app/controlplane/pkg/biz/organization.go | 2 +- app/controlplane/pkg/data/ent/schema/organization.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/controlplane/internal/server/grpc.go b/app/controlplane/internal/server/grpc.go index 739487c09..109464a59 100644 --- a/app/controlplane/internal/server/grpc.go +++ b/app/controlplane/internal/server/grpc.go @@ -211,7 +211,7 @@ func craftMiddleware(opts *Opts) []middleware.Middleware { selector.Server( // 2.d- Set its organization usercontext.WithCurrentOrganizationMiddleware(opts.UserUseCase, opts.OrganizationUseCase, logHelper), - // 2.e- Block write operations on suspended orgs + // 2.e- Block all operations on suspended orgs usercontext.WithSuspensionMiddleware(), // 3 - Check user/token authorization authzMiddleware.WithAuthzMiddleware(opts.AuthzUseCase, logHelper), @@ -253,7 +253,7 @@ func craftMiddleware(opts *Opts) []middleware.Middleware { usercontext.WithAttestationContextFromFederatedInfo(opts.OrganizationUseCase, logHelper), // Store all memberships in the context usercontext.WithCurrentMembershipsMiddleware(opts.MembershipUseCase, opts.MembershipsCache), - // 2.e - Block write operations on suspended orgs + // 2.e - Block all operations on suspended orgs usercontext.WithSuspensionMiddleware(), // 3 - Update API Token last usage usercontext.WithAPITokenUsageUpdater(opts.APITokenUseCase, logHelper), diff --git a/app/controlplane/pkg/biz/organization.go b/app/controlplane/pkg/biz/organization.go index bc7abbe3e..e0a3cbb8c 100644 --- a/app/controlplane/pkg/biz/organization.go +++ b/app/controlplane/pkg/biz/organization.go @@ -48,7 +48,7 @@ type Organization struct { APITokenInactivityThresholdDays *int // EnableAIAgentCollector enables automatic AI agent config collection during attestation init EnableAIAgentCollector bool - // Suspended indicates whether the organization is suspended (read-only mode) + // Suspended indicates whether the organization is suspended Suspended bool } diff --git a/app/controlplane/pkg/data/ent/schema/organization.go b/app/controlplane/pkg/data/ent/schema/organization.go index f0917f478..78ff0a6d9 100644 --- a/app/controlplane/pkg/data/ent/schema/organization.go +++ b/app/controlplane/pkg/data/ent/schema/organization.go @@ -60,7 +60,7 @@ func (Organization) Fields() []ent.Field { field.Int("api_token_inactivity_threshold_days").Optional().Nillable(), // enable_ai_agent_collector enables automatic AI agent config collection during attestation init field.Bool("enable_ai_agent_collector").Default(false), - // Suspended orgs are restricted to read-only operations. + // Suspended orgs are blocked from all operations. field.Bool("suspended").Default(false), } }