Skip to content

Commit a4f12cc

Browse files
committed
feat(organization): add suspension support
Signed-off-by: Sylwester Piskozub <sylwesterpiskozub@gmail.com>
1 parent b200376 commit a4f12cc

26 files changed

Lines changed: 717 additions & 36 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 write 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 write 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: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package usercontext
2+
3+
import (
4+
"context"
5+
"regexp"
6+
7+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities"
8+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
9+
errorsAPI "github.com/go-kratos/kratos/v2/errors"
10+
"github.com/go-kratos/kratos/v2/middleware"
11+
"github.com/go-kratos/kratos/v2/transport"
12+
)
13+
14+
// readOnlyActions are policy actions considered safe during suspension.
15+
var readOnlyActions = map[string]bool{
16+
authz.ActionRead: true,
17+
authz.ActionList: true,
18+
}
19+
20+
// suspensionExemptRegexp matches operations that are allowed when suspended even though
21+
// they cannot be classified as read-only via ServerOperationsMap policies.
22+
// This covers two cases:
23+
// - Empty-policy reads: operations mapped with {} in ServerOperationsMap that are actually reads
24+
// - Self-service writes: operations the user needs to leave/delete even when suspended
25+
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$`)
26+
27+
// WithSuspensionMiddleware blocks write operations when the current organization is suspended.
28+
//
29+
// Classification logic:
30+
// 1. If the operation has policies in ServerOperationsMap with only read/list actions -> allow
31+
// 2. If the operation matches suspensionExemptRegexp -> allow
32+
// 3. Everything else (writes, empty policies, unmapped operations) -> block
33+
func WithSuspensionMiddleware() middleware.Middleware {
34+
return func(handler middleware.Handler) middleware.Handler {
35+
return func(ctx context.Context, req interface{}) (interface{}, error) {
36+
org := entities.CurrentOrg(ctx)
37+
// No org context (e.g., user info, status endpoints), pass through
38+
if org == nil {
39+
return handler(ctx, req)
40+
}
41+
42+
if !org.Suspended {
43+
return handler(ctx, req)
44+
}
45+
46+
// Org is suspended, determine if the operation is read-only
47+
t, ok := transport.FromServerContext(ctx)
48+
if !ok {
49+
return nil, suspendedError()
50+
}
51+
52+
apiOperation := t.Operation()
53+
54+
// Check exemptions first
55+
if suspensionExemptRegexp.MatchString(apiOperation) {
56+
return handler(ctx, req)
57+
}
58+
59+
// Look up policies in ServerOperationsMap
60+
policies, err := authz.PoliciesLookup(apiOperation)
61+
if err != nil {
62+
// Operation not in ServerOperationsMap, block by default
63+
return nil, suspendedError()
64+
}
65+
66+
// Empty policies, no action metadata to classify as read-only, block
67+
if len(policies) == 0 {
68+
return nil, suspendedError()
69+
}
70+
71+
// Allow only if ALL policy actions are read-only
72+
for _, p := range policies {
73+
if !readOnlyActions[p.Action] {
74+
return nil, suspendedError()
75+
}
76+
}
77+
78+
return handler(ctx, req)
79+
}
80+
}
81+
}
82+
83+
func suspendedError() error {
84+
return errorsAPI.Forbidden("suspended", "organization is suspended, read-only access only")
85+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package usercontext
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities"
8+
"github.com/go-kratos/kratos/v2/transport"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
type suspensionMockTransport struct {
14+
operation string
15+
}
16+
17+
func (tr *suspensionMockTransport) Kind() transport.Kind { return transport.KindGRPC }
18+
func (tr *suspensionMockTransport) Endpoint() string { return "" }
19+
func (tr *suspensionMockTransport) Operation() string { return tr.operation }
20+
func (tr *suspensionMockTransport) RequestHeader() transport.Header { return nil }
21+
func (tr *suspensionMockTransport) ReplyHeader() transport.Header { return nil }
22+
23+
var passHandler = func(_ context.Context, _ interface{}) (interface{}, error) { return "ok", nil }
24+
25+
func TestWithSuspensionMiddleware(t *testing.T) {
26+
suspendedOrg := &entities.Org{ID: "org-1", Name: "test", Suspended: true}
27+
activeOrg := &entities.Org{ID: "org-1", Name: "test", Suspended: false}
28+
29+
tests := []struct {
30+
name string
31+
org *entities.Org
32+
operation string
33+
wantErr bool
34+
wantMsg string
35+
}{
36+
{
37+
name: "no org context passes through",
38+
org: nil,
39+
wantErr: false,
40+
},
41+
{
42+
name: "non-suspended org passes through",
43+
org: activeOrg,
44+
operation: "/controlplane.v1.WorkflowService/Create",
45+
wantErr: false,
46+
},
47+
{
48+
name: "suspended org allows read operation",
49+
org: suspendedOrg,
50+
operation: "/controlplane.v1.ReferrerService/DiscoverPrivate",
51+
wantErr: false,
52+
},
53+
{
54+
name: "suspended org allows list operation",
55+
org: suspendedOrg,
56+
operation: "/controlplane.v1.WorkflowService/List",
57+
wantErr: false,
58+
},
59+
{
60+
name: "suspended org allows read policy operation",
61+
org: suspendedOrg,
62+
operation: "/controlplane.v1.ContextService/Current",
63+
wantErr: false,
64+
},
65+
{
66+
name: "suspended org allows exempt empty-policy read",
67+
org: suspendedOrg,
68+
operation: "/controlplane.v1.CASCredentialsService/Get",
69+
wantErr: false,
70+
},
71+
{
72+
name: "suspended org allows exempt navigation operation",
73+
org: suspendedOrg,
74+
operation: "/controlplane.v1.UserService/ListMemberships",
75+
wantErr: false,
76+
},
77+
{
78+
name: "suspended org allows exempt group read",
79+
org: suspendedOrg,
80+
operation: "/controlplane.v1.GroupService/ListMembers",
81+
wantErr: false,
82+
},
83+
{
84+
name: "suspended org allows self-service org delete",
85+
org: suspendedOrg,
86+
operation: "/controlplane.v1.OrganizationService/Delete",
87+
wantErr: false,
88+
},
89+
{
90+
name: "suspended org allows self-service leave org",
91+
org: suspendedOrg,
92+
operation: "/controlplane.v1.UserService/DeleteMembership",
93+
wantErr: false,
94+
},
95+
{
96+
name: "suspended org allows self-service delete account",
97+
org: suspendedOrg,
98+
operation: "/controlplane.v1.AuthService/DeleteAccount",
99+
wantErr: false,
100+
},
101+
{
102+
name: "suspended org blocks empty-policy write",
103+
org: suspendedOrg,
104+
operation: "/controlplane.v1.GroupService/AddMember",
105+
wantErr: true,
106+
wantMsg: "suspended",
107+
},
108+
{
109+
name: "suspended org blocks another empty-policy write",
110+
org: suspendedOrg,
111+
operation: "/controlplane.v1.GroupService/RemoveMember",
112+
wantErr: true,
113+
wantMsg: "suspended",
114+
},
115+
{
116+
name: "suspended org blocks empty-policy org create",
117+
org: suspendedOrg,
118+
operation: "/controlplane.v1.OrganizationService/Create",
119+
wantErr: true,
120+
wantMsg: "suspended",
121+
},
122+
{
123+
name: "suspended org blocks unmapped write",
124+
org: suspendedOrg,
125+
operation: "/controlplane.v1.CASBackendService/Update",
126+
wantErr: true,
127+
wantMsg: "suspended",
128+
},
129+
{
130+
name: "suspended org blocks another unmapped write",
131+
org: suspendedOrg,
132+
operation: "/controlplane.v1.OrgInvitationService/Revoke",
133+
wantErr: true,
134+
wantMsg: "suspended",
135+
},
136+
{
137+
name: "suspended org blocks attestation write",
138+
org: suspendedOrg,
139+
operation: "/controlplane.v1.AttestationService/Init",
140+
wantErr: true,
141+
wantMsg: "suspended",
142+
},
143+
{
144+
name: "suspended org blocks attestation state write",
145+
org: suspendedOrg,
146+
operation: "/controlplane.v1.AttestationStateService/Save",
147+
wantErr: true,
148+
wantMsg: "suspended",
149+
},
150+
{
151+
name: "suspended org blocks signing write",
152+
org: suspendedOrg,
153+
operation: "/controlplane.v1.SigningService/GenerateSigningCert",
154+
wantErr: true,
155+
wantMsg: "suspended",
156+
},
157+
{
158+
name: "suspended org blocks mapped write policy",
159+
org: suspendedOrg,
160+
operation: "/controlplane.v1.WorkflowService/Create",
161+
wantErr: true,
162+
wantMsg: "suspended",
163+
},
164+
{
165+
name: "suspended org blocks mapped update policy",
166+
org: suspendedOrg,
167+
operation: "/controlplane.v1.CASBackendService/Revalidate",
168+
wantErr: true,
169+
wantMsg: "suspended",
170+
},
171+
{
172+
name: "suspended org blocks OrganizationService/DeleteMembership despite Delete being exempt",
173+
org: suspendedOrg,
174+
operation: "/controlplane.v1.OrganizationService/DeleteMembership",
175+
wantErr: true,
176+
wantMsg: "suspended",
177+
},
178+
{
179+
name: "suspended org blocks unknown future endpoint",
180+
org: suspendedOrg,
181+
operation: "/controlplane.v1.NewService/SomeWrite",
182+
wantErr: true,
183+
wantMsg: "suspended",
184+
},
185+
{
186+
name: "suspended org with no transport context is blocked",
187+
org: suspendedOrg,
188+
wantErr: true,
189+
wantMsg: "suspended",
190+
},
191+
}
192+
193+
for _, tt := range tests {
194+
t.Run(tt.name, func(t *testing.T) {
195+
ctx := context.Background()
196+
197+
if tt.org != nil {
198+
ctx = entities.WithCurrentOrg(ctx, tt.org)
199+
}
200+
201+
if tt.operation != "" {
202+
ctx = transport.NewServerContext(ctx, &suspensionMockTransport{operation: tt.operation})
203+
}
204+
205+
m := WithSuspensionMiddleware()
206+
result, err := m(passHandler)(ctx, nil)
207+
208+
if tt.wantErr {
209+
require.Error(t, err)
210+
assert.Contains(t, err.Error(), tt.wantMsg)
211+
assert.Nil(t, result)
212+
} else {
213+
require.NoError(t, err)
214+
assert.Equal(t, "ok", result)
215+
}
216+
})
217+
}
218+
}

0 commit comments

Comments
 (0)