-
Notifications
You must be signed in to change notification settings - Fork 114
feat(api-server): RBAC enforcement with scope-aware authorization #1660
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 19 commits
f7191f9
6f757e1
c51e5e0
f60ebf8
234beb6
9cf280e
5049fde
09da075
f480919
93da15d
92db2de
7b07b1a
636a23c
ab11b5c
8809bb8
730e3c5
5391597
e210732
cb6be83
33977a3
65dc811
e9826d5
348eb4a
9b5c5d7
55a9772
e70b3de
f93c181
a12498e
8a1dc2b
321c69d
f13bc88
5bab7b7
135af17
15c0fcc
f10507b
e75de97
98b4c22
4d9010a
3428df1
5e3d19c
478ad7f
39bacd9
d4e07a5
44e82bd
20547eb
e9ce4c2
97ffeef
c2a7e3b
969a634
eee4b99
6af2da5
251c01f
5b7a6fd
0c3ed32
e7df51c
e2e2604
b92b839
34b725b
4efbff2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "context" | ||
| "flag" | ||
| "fmt" | ||
|
|
||
| "github.com/golang/glog" | ||
| "github.com/openshift-online/rh-trex-ai/pkg/api" | ||
| "github.com/openshift-online/rh-trex-ai/pkg/config" | ||
| "github.com/openshift-online/rh-trex-ai/pkg/db/db_session" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| func NewSeedAdminCommand() *cobra.Command { | ||
| dbConfig := config.NewDatabaseConfig() | ||
| var username string | ||
|
|
||
| cmd := &cobra.Command{ | ||
| Use: "seed-admin", | ||
| Short: "Create the initial platform:admin RoleBinding", | ||
| Long: "Seeds the first platform:admin user. This breaks the bootstrap chicken-and-egg: RBAC endpoints are themselves gated, so the first admin cannot grant themselves access through the API.", | ||
| Run: func(cmd *cobra.Command, args []string) { | ||
| if err := dbConfig.ReadFiles(); err != nil { | ||
| glog.Fatal(err) | ||
| } | ||
|
|
||
| connection := db_session.NewProdFactory(dbConfig) | ||
| db := connection.New(context.Background()) | ||
|
|
||
| // Upsert user | ||
| userID := api.NewID() | ||
| result := db.Exec( | ||
| `INSERT INTO users (id, username, name, created_at, updated_at) | ||
| VALUES (?, ?, ?, NOW(), NOW()) | ||
| ON CONFLICT (username) WHERE deleted_at IS NULL DO NOTHING`, | ||
| userID, username, username, | ||
| ) | ||
| if result.Error != nil { | ||
| glog.Fatalf("Failed to upsert user: %v", result.Error) | ||
| } | ||
|
|
||
| // Resolve actual user ID (may already exist) | ||
| var resolvedUserID string | ||
| if err := db.Raw(`SELECT id FROM users WHERE username = ? AND deleted_at IS NULL`, username).Scan(&resolvedUserID).Error; err != nil { | ||
| glog.Fatalf("Failed to resolve user ID: %v", err) | ||
| } | ||
|
|
||
| // Look up platform:admin role | ||
| var roleID string | ||
| if err := db.Raw(`SELECT id FROM roles WHERE name = 'platform:admin' AND deleted_at IS NULL`).Scan(&roleID).Error; err != nil || roleID == "" { | ||
| glog.Fatal("platform:admin role not found — run migrations first") | ||
| } | ||
|
|
||
| // Create global binding (idempotent) | ||
| bindingResult := db.Exec( | ||
| `INSERT INTO role_bindings (id, role_id, scope, user_id, created_at, updated_at) | ||
| SELECT ?, ?, 'global', ?, NOW(), NOW() | ||
| WHERE NOT EXISTS ( | ||
| SELECT 1 FROM role_bindings | ||
| WHERE role_id = ? AND scope = 'global' AND user_id = ? AND deleted_at IS NULL | ||
| )`, | ||
| api.NewID(), roleID, resolvedUserID, roleID, resolvedUserID, | ||
| ) | ||
| if bindingResult.Error != nil { | ||
| glog.Fatalf("Failed to create admin binding: %v", bindingResult.Error) | ||
| } | ||
|
|
||
| if bindingResult.RowsAffected == 0 { | ||
| fmt.Printf("platform:admin binding already exists for user %q\n", username) | ||
| } else { | ||
| fmt.Printf("platform:admin binding created for user %q (id=%s)\n", username, resolvedUserID) | ||
| } | ||
| }, | ||
| } | ||
|
|
||
| cmd.Flags().StringVar(&username, "username", "", "Username of the admin to seed (required)") | ||
| _ = cmd.MarkFlagRequired("username") | ||
| dbConfig.AddFlags(cmd.PersistentFlags()) | ||
| cmd.PersistentFlags().AddGoFlagSet(flag.CommandLine) | ||
| return cmd | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| package rbac | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "strings" | ||
|
|
||
| "github.com/openshift-online/rh-trex-ai/pkg/services" | ||
| ) | ||
|
|
||
| type authResultKey struct{} | ||
|
|
||
| type AuthResult struct { | ||
| Username string | ||
| IsGlobalAdmin bool | ||
| ProjectIDs []string // nil = global access (all projects) | ||
| CredentialIDs []string // nil = global access (all credentials) | ||
| } | ||
|
|
||
| func SetAuthResult(ctx context.Context, result *AuthResult) context.Context { | ||
| return context.WithValue(ctx, authResultKey{}, result) | ||
| } | ||
|
|
||
| func GetAuthResult(ctx context.Context) *AuthResult { | ||
| v, _ := ctx.Value(authResultKey{}).(*AuthResult) | ||
| return v | ||
| } | ||
|
|
||
| // ApplyListFilter restricts list results to the caller's authorized scope. | ||
| // filterColumn is the DB column to filter on (e.g. "id" for projects, "project_id" for sessions). | ||
| // useCredentialIDs controls whether to filter by credential IDs instead of project IDs. | ||
| // Returns false if the user has zero authorized IDs (caller should return empty list). | ||
| func ApplyListFilter(ctx context.Context, listArgs *services.ListArguments, filterColumn string, useCredentialIDs bool) bool { | ||
| auth := GetAuthResult(ctx) | ||
| if auth == nil || auth.IsGlobalAdmin { | ||
| return true | ||
| } | ||
|
|
||
| var ids []string | ||
| if useCredentialIDs { | ||
| ids = auth.CredentialIDs | ||
| } else { | ||
| ids = auth.ProjectIDs | ||
| } | ||
|
|
||
| if len(ids) == 0 { | ||
| return false | ||
| } | ||
|
|
||
| quoted := make([]string, len(ids)) | ||
| for i, id := range ids { | ||
| quoted[i] = fmt.Sprintf("'%s'", strings.ReplaceAll(id, "'", "''")) | ||
| } | ||
| scopeFilter := fmt.Sprintf("%s in (%s)", filterColumn, strings.Join(quoted, ",")) | ||
|
|
||
| if listArgs.Search != "" { | ||
| listArgs.Search = fmt.Sprintf("(%s) and (%s)", listArgs.Search, scopeFilter) | ||
| } else { | ||
| listArgs.Search = scopeFilter | ||
| } | ||
| return true | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,194 @@ | ||
| package rbac | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "strings" | ||
|
|
||
| "github.com/openshift-online/rh-trex-ai/pkg/db" | ||
| "gorm.io/gorm" | ||
| ) | ||
|
|
||
| type bindingRow struct { | ||
| RoleID string | ||
| RoleName string | ||
| Scope string | ||
| UserID *string | ||
| ProjectID *string | ||
| AgentID *string | ||
| SessionID *string | ||
| CredentialID *string | ||
| Permissions string | ||
| } | ||
|
|
||
| type Evaluator struct { | ||
| sessionFactory *db.SessionFactory | ||
| } | ||
|
|
||
| func NewEvaluator(sessionFactory *db.SessionFactory) *Evaluator { | ||
| return &Evaluator{sessionFactory: sessionFactory} | ||
| } | ||
|
|
||
| func (e *Evaluator) fetchBindings(g *gorm.DB, username string) ([]bindingRow, error) { | ||
| var rows []bindingRow | ||
| err := g.Raw(` | ||
| SELECT rb.role_id, r.name AS role_name, rb.scope, | ||
| rb.user_id, rb.project_id, rb.agent_id, | ||
| rb.session_id, rb.credential_id, | ||
| r.permissions | ||
| FROM role_bindings rb | ||
| JOIN roles r ON r.id = rb.role_id | ||
| WHERE rb.user_id = ? | ||
| AND rb.deleted_at IS NULL | ||
| AND r.deleted_at IS NULL | ||
| `, username).Scan(&rows).Error | ||
| return rows, err | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Binding lookup uses username against Line 44 filters Suggested query fix- FROM role_bindings rb
- JOIN roles r ON r.id = rb.role_id
- WHERE rb.user_id = ?
+ FROM role_bindings rb
+ JOIN users u ON u.id = rb.user_id
+ JOIN roles r ON r.id = rb.role_id
+ WHERE u.username = ?
+ AND u.deleted_at IS NULL
AND rb.deleted_at IS NULL
AND r.deleted_at IS NULL🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| func (e *Evaluator) Evaluate(ctx context.Context, username string, resource Resource, action Action, scope RequestScope) (bool, error) { | ||
| g := (*e.sessionFactory).New(ctx) | ||
|
|
||
| bindings, err := e.fetchBindings(g, username) | ||
| if err != nil { | ||
| return false, err | ||
| } | ||
| if len(bindings) == 0 { | ||
| return false, nil | ||
| } | ||
|
|
||
| requiredPerm := string(resource) + ":" + string(action) | ||
|
|
||
| if scope.SessionID != "" && scope.ProjectID == "" { | ||
| projectID, lookupErr := e.resolveSessionProject(g, scope.SessionID) | ||
| if lookupErr == nil && projectID != "" { | ||
| scope.ProjectID = projectID | ||
| } | ||
| } | ||
|
|
||
| for _, b := range bindings { | ||
| if !bindingMatchesPermission(b.Permissions, requiredPerm) { | ||
| continue | ||
| } | ||
| if bindingCoversScope(b, scope) { | ||
| return true, nil | ||
| } | ||
| } | ||
|
|
||
| return false, nil | ||
| } | ||
|
|
||
| func (e *Evaluator) AuthorizedProjectIDs(ctx context.Context, username string) (projectIDs []string, isGlobal bool, err error) { | ||
| g := (*e.sessionFactory).New(ctx) | ||
|
|
||
| bindings, fetchErr := e.fetchBindings(g, username) | ||
| if fetchErr != nil { | ||
| return nil, false, fetchErr | ||
| } | ||
|
|
||
| seen := map[string]bool{} | ||
| for _, b := range bindings { | ||
| if b.Scope == string(ScopeGlobal) { | ||
| return nil, true, nil | ||
| } | ||
| if b.Scope == string(ScopeProject) && b.ProjectID != nil { | ||
| if !seen[*b.ProjectID] { | ||
| seen[*b.ProjectID] = true | ||
| projectIDs = append(projectIDs, *b.ProjectID) | ||
| } | ||
| } | ||
| } | ||
| return projectIDs, false, nil | ||
| } | ||
|
|
||
| func (e *Evaluator) AuthorizedCredentialIDs(ctx context.Context, username string) (credentialIDs []string, isGlobal bool, err error) { | ||
| g := (*e.sessionFactory).New(ctx) | ||
|
|
||
| bindings, fetchErr := e.fetchBindings(g, username) | ||
| if fetchErr != nil { | ||
| return nil, false, fetchErr | ||
| } | ||
|
|
||
| seen := map[string]bool{} | ||
| for _, b := range bindings { | ||
| if b.Scope == string(ScopeGlobal) { | ||
| return nil, true, nil | ||
| } | ||
| if b.Scope == string(ScopeCredential) && b.CredentialID != nil { | ||
| if !seen[*b.CredentialID] { | ||
| seen[*b.CredentialID] = true | ||
| credentialIDs = append(credentialIDs, *b.CredentialID) | ||
| } | ||
| } | ||
| } | ||
| return credentialIDs, false, nil | ||
| } | ||
|
|
||
| func (e *Evaluator) resolveSessionProject(g *gorm.DB, sessionID string) (string, error) { | ||
| var projectID string | ||
| err := g.Raw(`SELECT COALESCE(project_id, '') FROM sessions WHERE id = ? AND deleted_at IS NULL`, sessionID).Scan(&projectID).Error | ||
| return projectID, err | ||
| } | ||
|
|
||
| func bindingMatchesPermission(permissionsJSON, required string) bool { | ||
| var perms []string | ||
| if err := json.Unmarshal([]byte(permissionsJSON), &perms); err != nil { | ||
| return false | ||
| } | ||
|
|
||
| reqParts := strings.SplitN(required, ":", 2) | ||
| if len(reqParts) != 2 { | ||
| return false | ||
| } | ||
| reqResource, reqAction := reqParts[0], reqParts[1] | ||
|
|
||
| for _, perm := range perms { | ||
| if perm == "*:*" { | ||
| return true | ||
| } | ||
| parts := strings.SplitN(perm, ":", 2) | ||
| if len(parts) != 2 { | ||
| continue | ||
| } | ||
| r, a := parts[0], parts[1] | ||
| resourceMatch := r == "*" || r == reqResource | ||
| actionMatch := a == "*" || a == reqAction | ||
| if resourceMatch && actionMatch { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| func bindingCoversScope(b bindingRow, reqScope RequestScope) bool { | ||
| switch ScopeLevel(b.Scope) { | ||
| case ScopeGlobal: | ||
| return true | ||
|
|
||
| case ScopeProject: | ||
| if b.ProjectID == nil { | ||
| return false | ||
| } | ||
| return reqScope.ProjectID == *b.ProjectID | ||
|
|
||
| case ScopeAgent: | ||
| if b.AgentID == nil { | ||
| return false | ||
| } | ||
| return reqScope.AgentID == *b.AgentID | ||
|
|
||
| case ScopeSession: | ||
| if b.SessionID == nil { | ||
| return false | ||
| } | ||
| return reqScope.SessionID == *b.SessionID | ||
|
|
||
| case ScopeCredential: | ||
| if b.CredentialID == nil { | ||
| return false | ||
| } | ||
| return reqScope.CredentialID == *b.CredentialID | ||
|
|
||
| default: | ||
| return false | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.