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
14 changes: 12 additions & 2 deletions app/controlplane/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ func init() {
flag.StringVar(&flagconf, "conf", "../configs", "config path, eg: -conf config.yaml")
}

func newApp(logger log.Logger, gs *grpc.Server, hs *http.Server, ms *server.HTTPMetricsServer, profilerSvc *server.HTTPProfilerServer, expirer *biz.WorkflowRunExpirerUseCase, plugins sdk.AvailablePlugins, tokenSync *biz.APITokenSyncerUseCase, cfg *conf.Bootstrap) *app {
func newApp(logger log.Logger, gs *grpc.Server, hs *http.Server, ms *server.HTTPMetricsServer, profilerSvc *server.HTTPProfilerServer,
expirer *biz.WorkflowRunExpirerUseCase, plugins sdk.AvailablePlugins, tokenSync *biz.APITokenSyncerUseCase,
userAccessSyncer *biz.UserAccessSyncerUseCase, cfg *conf.Bootstrap) *app {
servers := []transport.Server{gs, hs, ms}
if cfg.EnableProfiler {
servers = append(servers, profilerSvc)
Expand All @@ -76,7 +78,7 @@ func newApp(logger log.Logger, gs *grpc.Server, hs *http.Server, ms *server.HTTP
kratos.Metadata(map[string]string{}),
kratos.Logger(logger),
kratos.Server(servers...),
), expirer, plugins, tokenSync}
), expirer, plugins, tokenSync, userAccessSyncer}
}

func main() {
Expand Down Expand Up @@ -158,6 +160,13 @@ func main() {
}
}()

// Sync user access
go func() {
if err := app.userAccessSyncer.StartSyncingUserAccess(ctx); err != nil {
_ = logger.Log(log.LevelError, "msg", "syncing user access", "error", err)
}
}()

// start and wait for stop signal
if err := app.Run(); err != nil {
panic(err)
Expand All @@ -170,6 +179,7 @@ type app struct {
runsExpirer *biz.WorkflowRunExpirerUseCase
availablePlugins sdk.AvailablePlugins
tokenAuthSyncer *biz.APITokenSyncerUseCase
userAccessSyncer *biz.UserAccessSyncerUseCase
}

// Connection to nats is optional, if not configured, pubsub will be disabled
Expand Down
5 changes: 5 additions & 0 deletions app/controlplane/cmd/wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func wireApp(*conf.Bootstrap, credentials.ReaderWriter, log.Logger, sdk.Availabl
newNatsConnection,
auditor.NewAuditLogPublisher,
newCASServerOptions,
newAuthAllowList,
),
)
}
Expand Down Expand Up @@ -95,3 +96,7 @@ func newCASServerOptions(in *conf.Bootstrap_CASServer) *biz.CASServerDefaultOpts
DefaultEntryMaxSize: in.GetDefaultEntryMaxSize(),
}
}

func newAuthAllowList(conf *conf.Bootstrap) *conf.Auth_AllowList {
return conf.Auth.GetAllowList()
}
8 changes: 7 additions & 1 deletion app/controlplane/cmd/wire_gen.go

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

40 changes: 3 additions & 37 deletions app/controlplane/internal/usercontext/allowlist_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ package usercontext
import (
"context"
"fmt"
"strings"

v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities"
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"

"github.com/go-kratos/kratos/v2/middleware"
"github.com/go-kratos/kratos/v2/transport"
)
Expand Down Expand Up @@ -53,7 +54,7 @@ func CheckUserInAllowList(allowList *conf.Auth_AllowList) middleware.Middleware
}

// If there are not items in the allowList we allow all users
allow, err := inAllowList(allowList.GetRules(), user.Email)
allow, err := biz.UserEmailInAllowlist(allowList.GetRules(), user.Email)
if err != nil {
return nil, v1.ErrorAllowListErrorNotInList("error checking user in allowList: %v", err)
}
Expand Down Expand Up @@ -83,38 +84,3 @@ func selectedRoute(ctx context.Context, selectedRoutes []string) bool {

return false
}

func inAllowList(allowList []string, email string) (bool, error) {
for _, allowListEntry := range allowList {
// it's a direct email match
if allowListEntry == email {
return true, nil
}

// Check if the entry is a domain and the email is part of it
// extract the domain from the allowList entry
// i.e if the entry is @cyberdyne.io, we get cyberdyne.io
domainComponent := strings.Split(allowListEntry, "@")
if len(domainComponent) != 2 {
return false, fmt.Errorf("invalid domain entry: %q", allowListEntry)
}

// it's not a domain since it contains an username, then continue
if domainComponent[0] != "" {
continue
}

// Compare the domains
emailComponents := strings.Split(email, "@")
if len(emailComponents) != 2 {
return false, fmt.Errorf("invalid email: %q", email)
}

// check if against a potential domain entry in the allowList
if emailComponents[1] == domainComponent[1] {
return true, nil
}
}

return false, nil
}
1 change: 1 addition & 0 deletions app/controlplane/pkg/biz/biz.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ var ProviderSet = wire.NewSet(
NewProjectVersionUseCase,
NewProjectsUseCase,
NewAuditorUseCase,
NewUserAccessSyncerUseCase,
wire.Bind(new(PromObservable), new(*PrometheusUseCase)),
wire.Struct(new(NewIntegrationUseCaseOpts), "*"),
wire.Struct(new(NewUserUseCaseParams), "*"),
Expand Down
12 changes: 9 additions & 3 deletions app/controlplane/pkg/biz/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,27 @@ import (
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/auditor/events"
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
config "github.com/chainloop-dev/chainloop/app/controlplane/pkg/conf/controlplane/config/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/pagination"

"github.com/go-kratos/kratos/v2/log"
"github.com/google/uuid"
)

type User struct {
ID string
Email string
CreatedAt *time.Time
ID string
Email string
CreatedAt *time.Time
HasRestrictedAccess bool
}

type UserRepo interface {
CreateByEmail(ctx context.Context, email string) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
FindByID(ctx context.Context, userID uuid.UUID) (*User, error)
Delete(ctx context.Context, userID uuid.UUID) error
FindAll(ctx context.Context, pagination *pagination.OffsetPaginationOpts) ([]*User, int, error)
UpdateAccess(ctx context.Context, userID uuid.UUID, isAccessRestricted bool) error
CountUsersWithRestrictedOrUnsetAccess(ctx context.Context) (int, error)
}

type UserOrgFinder interface {
Expand Down
192 changes: 192 additions & 0 deletions app/controlplane/pkg/biz/useraccess_syncer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
//
// Copyright 2025 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 biz

import (
"context"
"fmt"
"strings"
"time"

"github.com/go-kratos/kratos/v2/log"

conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/pagination"
"github.com/google/uuid"
)

type UserAccessSyncerUseCase struct {
logger *log.Helper
// Repositories
userRepo UserRepo
// Configuration
allowList *conf.Auth_AllowList
}

func NewUserAccessSyncerUseCase(logger log.Logger, userRepo UserRepo, allowList *conf.Auth_AllowList) *UserAccessSyncerUseCase {
return &UserAccessSyncerUseCase{
userRepo: userRepo,
allowList: allowList,
logger: log.NewHelper(log.With(logger, "component", "biz/user_access_syncer")),
}
}

// StartSyncingUserAccess starts syncing the access restriction status of all users based on the allowlist
func (u *UserAccessSyncerUseCase) StartSyncingUserAccess(ctx context.Context) error {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
u.logger.Infow("msg", "stopping user access sync")
return nil
case <-ticker.C:
u.logger.Infow("msg", "Syncing user access")

// Update the access restriction status of all users based on the allowlist
if err := u.updateUserAccessBasedOnAllowList(ctx); err != nil {
return fmt.Errorf("update user access based on allow list: %w", err)
}

u.logger.Infow("msg", "User access synced")
}
}
}

// updateUserAccessBasedOnAllowList updates the access restriction status of all users based on the allowlist
func (u *UserAccessSyncerUseCase) updateUserAccessBasedOnAllowList(ctx context.Context) error {
// Count the number of users with restricted access or brand-new users where its access has not been set yet
usersWithRestrictedAccess, err := u.userRepo.CountUsersWithRestrictedOrUnsetAccess(ctx)
if err != nil {
return fmt.Errorf("count users with access: %w", err)
}

// If the allowlist is empty and there are no users with restricted access, we can skip the sync
if u.allowList == nil || len(u.allowList.GetRules()) == 0 {
if usersWithRestrictedAccess == 0 {
return nil
}
}

// Sync the access restriction status of all users based on the allowlist
if err := u.syncUserAccess(ctx); err != nil {
return fmt.Errorf("sync user access: %w", err)
}

return nil
}

// syncUserAccess syncs the access restriction status of all users based on the allowlist
func (u *UserAccessSyncerUseCase) syncUserAccess(ctx context.Context) error {
var (
offset = 1
limit = 50
)

for {
pgOpts, err := pagination.NewOffsetPaginationOpts(offset, limit)
if err != nil {
return fmt.Errorf("failed to create pagination options: %w", err)
}

users, _, err := u.userRepo.FindAll(ctx, pgOpts)
if err != nil {
return fmt.Errorf("failed to list users: %w", err)
}

for _, user := range users {
if err := u.updateUserAccessRestriction(ctx, user); err != nil {
return fmt.Errorf("failed to update user access: %w", err)
}
}

if len(users) < limit {
break
}

offset++
}

return nil
}

// updateUserAccessRestriction updates the access restriction status of a user
func (u *UserAccessSyncerUseCase) updateUserAccessRestriction(ctx context.Context, user *User) error {
isAllowListDeactivated := u.allowList == nil || len(u.allowList.GetRules()) == 0

var isAccessRestricted bool

// If the allowlist is empty, we deactivate the access restriction for all users
if isAllowListDeactivated {
isAccessRestricted = false
} else {
// Check if the user email is in the allowlist and update the access restriction status accordingly
allow, err := UserEmailInAllowlist(u.allowList.GetRules(), user.Email)
if err != nil {
return fmt.Errorf("error checking user in allowList: %w", err)
}

isAccessRestricted = !allow
}

parsedUserUUID, err := uuid.Parse(user.ID)
if err != nil {
return fmt.Errorf("invalid user ID: %w", err)
}

if err := u.userRepo.UpdateAccess(ctx, parsedUserUUID, isAccessRestricted); err != nil {
return fmt.Errorf("failed to update user access: %w", err)
}

return nil
}

// UserEmailInAllowlist checks if the user email is in the allowlist
func UserEmailInAllowlist(allowList []string, email string) (bool, error) {
for _, allowListEntry := range allowList {
// it's a direct email match
if allowListEntry == email {
return true, nil
}

// Check if the entry is a domain and the email is part of it
// extract the domain from the allowList entry
// i.e if the entry is @cyberdyne.io, we get cyberdyne.io
domainComponent := strings.Split(allowListEntry, "@")
if len(domainComponent) != 2 {
return false, fmt.Errorf("invalid domain entry: %q", allowListEntry)
}

// it's not a domain since it contains an username, then continue
if domainComponent[0] != "" {
continue
}

// Compare the domains
emailComponents := strings.Split(email, "@")
if len(emailComponents) != 2 {
return false, fmt.Errorf("invalid email: %q", email)
}

// check if against a potential domain entry in the allowList
if emailComponents[1] == domainComponent[1] {
return true, nil
}
}

return false, nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Modify "users" table
ALTER TABLE "users" ADD COLUMN "has_restricted_access" boolean NULL;
-- Create index "user_has_restricted_access" to table: "users"
CREATE INDEX "user_has_restricted_access" ON "users" ("has_restricted_access");
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:YVPucsMOwyVrKFmOgfjfQ4xLEyqpppOJ7CNEXsVjiTo=
h1:6sY+6+wQ/2pBGPP/+t/PcZkL1HxZR2wcel340cIBLAo=
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 @@ -79,3 +79,4 @@ h1:YVPucsMOwyVrKFmOgfjfQ4xLEyqpppOJ7CNEXsVjiTo=
20250203084822.sql h1:xKQ2szI/uaQjz9mOthOE5SFO8wV/maJglRfciXjb+P8=
20250303153626.sql h1:y38iNqTO+lutsb2hPu+gepPDgSDmsTcWbgu7kMpbIzE=
20250326110627.sql h1:kTneMHSqpE7I8Gl88jjTy2olXpdg/np0yA45lqIxBic=
20250327120254.sql h1:g7J945QzvonLcydhUryeIt2qXX/BLRo8XdClt2B6264=
Loading
Loading