Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/controlplane/internal/server/grpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
Piskoo marked this conversation as resolved.
usercontext.WithSuspensionMiddleware(),
// 3 - Check user/token authorization
authzMiddleware.WithAuthzMiddleware(opts.AuthzUseCase, logHelper),
).Match(requireAllButOrganizationOperationsMatcher()).Build(),
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/controlplane/internal/usercontext/apitoken_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions app/controlplane/internal/usercontext/suspension_middleware.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
63 changes: 63 additions & 0 deletions app/controlplane/pkg/biz/mocks/OrganizationRepo.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions app/controlplane/pkg/biz/organization.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Modify "organizations" table
ALTER TABLE "organizations" ADD COLUMN "suspended" boolean NOT NULL DEFAULT false;
3 changes: 2 additions & 1 deletion app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down Expand Up @@ -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=
1 change: 1 addition & 0 deletions app/controlplane/pkg/data/ent/migrate/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading
Loading