diff --git a/app/controlplane/internal/server/grpc.go b/app/controlplane/internal/server/grpc.go index cebc15699..109464a59 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 all 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 all 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..268453a50 --- /dev/null +++ b/app/controlplane/internal/usercontext/suspension_middleware.go @@ -0,0 +1,43 @@ +// +// 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 ( + "context" + + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" + errorsAPI "github.com/go-kratos/kratos/v2/errors" + "github.com/go-kratos/kratos/v2/middleware" +) + +// 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) + if org == nil { + return handler(ctx, req) + } + + if org.Suspended { + return nil, errorsAPI.Forbidden("suspended", "organization is suspended") + } + + return handler(ctx, req) + } + } +} 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..4680c4194 --- /dev/null +++ b/app/controlplane/internal/usercontext/suspension_middleware_test.go @@ -0,0 +1,75 @@ +// +// 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 ( + "context" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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 + wantErr bool + }{ + { + name: "no org context passes through", + org: nil, + wantErr: false, + }, + { + name: "active org passes through", + org: activeOrg, + wantErr: false, + }, + { + name: "suspended org is blocked", + org: suspendedOrg, + wantErr: true, + }, + } + + 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) + } + + m := WithSuspensionMiddleware() + result, err := m(passHandler)(ctx, nil) + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "suspended") + assert.Nil(t, result) + } else { + require.NoError(t, err) + assert.Equal(t, "ok", result) + } + }) + } +} 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..e0a3cbb8c 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 + 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..78ff0a6d9 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 blocked from all 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, } }