Skip to content

Commit 6981c6e

Browse files
authored
feat(organization): add suspension support (#3007)
Signed-off-by: Sylwester Piskozub <sylwesterpiskozub@gmail.com>
1 parent 24eee2d commit 6981c6e

22 files changed

Lines changed: 408 additions & 10 deletions

app/controlplane/internal/server/grpc.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ func craftMiddleware(opts *Opts) []middleware.Middleware {
211211
selector.Server(
212212
// 2.d- Set its organization
213213
usercontext.WithCurrentOrganizationMiddleware(opts.UserUseCase, opts.OrganizationUseCase, logHelper),
214+
// 2.e- Block all operations on suspended orgs
215+
usercontext.WithSuspensionMiddleware(),
214216
// 3 - Check user/token authorization
215217
authzMiddleware.WithAuthzMiddleware(opts.AuthzUseCase, logHelper),
216218
).Match(requireAllButOrganizationOperationsMatcher()).Build(),
@@ -251,6 +253,8 @@ func craftMiddleware(opts *Opts) []middleware.Middleware {
251253
usercontext.WithAttestationContextFromFederatedInfo(opts.OrganizationUseCase, logHelper),
252254
// Store all memberships in the context
253255
usercontext.WithCurrentMembershipsMiddleware(opts.MembershipUseCase, opts.MembershipsCache),
256+
// 2.e - Block all operations on suspended orgs
257+
usercontext.WithSuspensionMiddleware(),
254258
// 3 - Update API Token last usage
255259
usercontext.WithAPITokenUsageUpdater(opts.APITokenUseCase, logHelper),
256260
// 4 - Validate the CAS Backend is fully configured and valid

app/controlplane/internal/usercontext/apitoken_middleware.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
200200
return nil, errors.New("organization not found")
201201
}
202202

203-
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt})
203+
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt, Suspended: org.Suspended})
204204
}
205205
// If no org header, org context remains unset, operations will either:
206206
// 1. Work without org context
@@ -214,7 +214,7 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
214214
}
215215

216216
// Set the current organization in the context
217-
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt})
217+
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt, Suspended: org.Suspended})
218218
}
219219

220220
ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{

app/controlplane/internal/usercontext/currentorganization_middleware.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ func setCurrentMembershipFromOrgName(ctx context.Context, user *entities.User, o
156156
role = authz.RoleInstanceAdmin
157157
} else {
158158
role = membership.Role
159-
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: membership.Org.Name, ID: membership.Org.ID, CreatedAt: membership.CreatedAt})
159+
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: membership.Org.Name, ID: membership.Org.ID, CreatedAt: membership.CreatedAt, Suspended: membership.Org.Suspended})
160160
}
161161

162162
// 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
175175
if err != nil {
176176
return nil, fmt.Errorf("failed to find organization: %w", err)
177177
}
178-
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt})
178+
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt, Suspended: org.Suspended})
179179
}
180180
} else {
181181
// if no membership and no instance admin, return error
@@ -202,7 +202,7 @@ func setCurrentOrganizationFromDB(ctx context.Context, user *entities.User, user
202202
return nil, errors.New("org not found")
203203
}
204204

205-
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: membership.Org.Name, ID: membership.Org.ID, CreatedAt: membership.CreatedAt})
205+
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: membership.Org.Name, ID: membership.Org.ID, CreatedAt: membership.CreatedAt, Suspended: membership.Org.Suspended})
206206

207207
// Set the authorization subject that will be used to check the policies
208208
ctx = WithAuthzSubject(ctx, string(membership.Role))

app/controlplane/internal/usercontext/entities/organization.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
type Org struct {
2424
ID, Name string
2525
CreatedAt *time.Time
26+
Suspended bool
2627
}
2728

2829
func WithCurrentOrg(ctx context.Context, org *Org) context.Context {

app/controlplane/internal/usercontext/federated_middleware.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func WithAttestationContextFromFederatedInfo(orgUC *biz.OrganizationUseCase, log
6161
}
6262

6363
// Set the current organization and API-Token in the context
64-
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt})
64+
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt, Suspended: org.Suspended})
6565
logger.Infow("msg", "[authN] processed credentials", "type", "Federated delegation")
6666

6767
return handler(ctx, req)

app/controlplane/internal/usercontext/robotaccount_middleware.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ func WithAttestationContextFromRobotAccount(robotAccountUseCase *biz.RobotAccoun
111111
return nil, fmt.Errorf("error retrieving the organization: %w", err)
112112
}
113113

114-
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt})
114+
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt, Suspended: org.Suspended})
115115

116116
// Check that the encoded workflow ID is the one associated with the robot account
117117
// NOTE: This in theory should not be necessary since currently we allow a robot account to be attached to ONLY ONE workflowID
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package usercontext
17+
18+
import (
19+
"context"
20+
21+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities"
22+
errorsAPI "github.com/go-kratos/kratos/v2/errors"
23+
"github.com/go-kratos/kratos/v2/middleware"
24+
)
25+
26+
// WithSuspensionMiddleware blocks all requests when the current organization is suspended.
27+
// If there is no org in context (e.g. status endpoints), the request passes through.
28+
func WithSuspensionMiddleware() middleware.Middleware {
29+
return func(handler middleware.Handler) middleware.Handler {
30+
return func(ctx context.Context, req interface{}) (interface{}, error) {
31+
org := entities.CurrentOrg(ctx)
32+
if org == nil {
33+
return handler(ctx, req)
34+
}
35+
36+
if org.Suspended {
37+
return nil, errorsAPI.Forbidden("suspended", "organization is suspended")
38+
}
39+
40+
return handler(ctx, req)
41+
}
42+
}
43+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//
2+
// Copyright 2026 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package usercontext
17+
18+
import (
19+
"context"
20+
"testing"
21+
22+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities"
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
)
26+
27+
var passHandler = func(_ context.Context, _ interface{}) (interface{}, error) { return "ok", nil }
28+
29+
func TestWithSuspensionMiddleware(t *testing.T) {
30+
suspendedOrg := &entities.Org{ID: "org-1", Name: "test", Suspended: true}
31+
activeOrg := &entities.Org{ID: "org-1", Name: "test", Suspended: false}
32+
33+
tests := []struct {
34+
name string
35+
org *entities.Org
36+
wantErr bool
37+
}{
38+
{
39+
name: "no org context passes through",
40+
org: nil,
41+
wantErr: false,
42+
},
43+
{
44+
name: "active org passes through",
45+
org: activeOrg,
46+
wantErr: false,
47+
},
48+
{
49+
name: "suspended org is blocked",
50+
org: suspendedOrg,
51+
wantErr: true,
52+
},
53+
}
54+
55+
for _, tt := range tests {
56+
t.Run(tt.name, func(t *testing.T) {
57+
ctx := context.Background()
58+
if tt.org != nil {
59+
ctx = entities.WithCurrentOrg(ctx, tt.org)
60+
}
61+
62+
m := WithSuspensionMiddleware()
63+
result, err := m(passHandler)(ctx, nil)
64+
65+
if tt.wantErr {
66+
require.Error(t, err)
67+
assert.Contains(t, err.Error(), "suspended")
68+
assert.Nil(t, result)
69+
} else {
70+
require.NoError(t, err)
71+
assert.Equal(t, "ok", result)
72+
}
73+
})
74+
}
75+
}

app/controlplane/pkg/biz/mocks/OrganizationRepo.go

Lines changed: 63 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/pkg/biz/organization.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ type Organization struct {
4848
APITokenInactivityThresholdDays *int
4949
// EnableAIAgentCollector enables automatic AI agent config collection during attestation init
5050
EnableAIAgentCollector bool
51+
// Suspended indicates whether the organization is suspended
52+
Suspended bool
5153
}
5254

5355
// OrganizationUpdateOpts holds optional fields for updating an organization.
@@ -70,6 +72,7 @@ type OrganizationRepo interface {
7072
Delete(ctx context.Context, ID uuid.UUID) error
7173
// FindWithTokenInactivityThreshold returns orgs that have api_token_inactivity_threshold_days set (non-nil).
7274
FindWithTokenInactivityThreshold(ctx context.Context) ([]*Organization, error)
75+
SetSuspended(ctx context.Context, id uuid.UUID, suspended bool) error
7376
}
7477

7578
type OrganizationUseCase struct {

0 commit comments

Comments
 (0)