Skip to content
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
f7191f9
Merge branch 'main' of https://github.com/ambient-code/platform
jsell-rh Jun 5, 2026
6f757e1
Merge branch 'main' of https://github.com/ambient-code/platform
jsell-rh Jun 5, 2026
c51e5e0
feat(api-server): implement scope-aware RBAC enforcement
jsell-rh Jun 5, 2026
f60ebf8
feat(api-server): add gRPC RBAC authorization for watch streams
jsell-rh Jun 5, 2026
234beb6
test(api-server): add RBAC enforcement integration tests
jsell-rh Jun 5, 2026
9cf280e
style(api-server): fix gofmt formatting in rbac package
jsell-rh Jun 5, 2026
5049fde
test(api-server): add RBAC enforcement e2e test script
jsell-rh Jun 5, 2026
09da075
feat(api-server): wire RBAC list filtering and fix e2e test failures
jsell-rh Jun 5, 2026
f480919
ci: add RBAC enforcement e2e test step to CI pipeline
jsell-rh Jun 5, 2026
93da15d
feat: new project button, escalation checks, e2e assertions
jsell-rh Jun 5, 2026
92db2de
fix(api-server): extract RBAC scope from request body for top-level P…
jsell-rh Jun 5, 2026
7b07b1a
fix(api-server): credential binding requires both credential:owner an…
jsell-rh Jun 5, 2026
636a23c
test(api-server): comprehensive RBAC e2e covering 30+ spec scenarios
jsell-rh Jun 5, 2026
ab11b5c
fix(api-server): scoped escalation checks + generative e2e matrix
jsell-rh Jun 5, 2026
8809bb8
fix(api-server): idempotent e2e cleanup + generative escalation matrix
jsell-rh Jun 5, 2026
730e3c5
fix(api-server): close RBAC gaps in session sub-resources and role bi…
jsell-rh Jun 5, 2026
5391597
fix(api-server): unique constraint on role_bindings + RBAC gap fixes
jsell-rh Jun 5, 2026
e210732
test(api-server): red tests for audit findings — 13 failures
jsell-rh Jun 5, 2026
cb6be83
fix(api-server): address 7 audit findings — PATCH escalation, list fi…
jsell-rh Jun 5, 2026
33977a3
fix(api-server): all 156 RBAC e2e tests passing — zero gaps
jsell-rh Jun 5, 2026
65dc811
fix(api-server): remove unused test helpers to fix CI lint
jsell-rh Jun 5, 2026
e9826d5
test(api-server): red tests proving PATCH scope widening attack
jsell-rh Jun 5, 2026
348eb4a
fix(api-server): close PATCH scope widening + nil sessionFactory guard
jsell-rh Jun 5, 2026
9b5c5d7
fix(api-server): close platform:viewer→admin escalation + fail-closed…
jsell-rh Jun 5, 2026
55a9772
fix(api-server): eliminate raw SQL, fix runner 404→502, session healt…
jsell-rh Jun 6, 2026
e70b3de
ci: add OpenAPI generated code drift check
jsell-rh Jun 6, 2026
f93c181
fix(api-server): fix OpenAPI generator + regenerate client
jsell-rh Jun 6, 2026
a12498e
fix: address CodeRabbit review findings + CI compile errors
jsell-rh Jun 6, 2026
8a1dc2b
fix(api-server): revert operationId, regenerate clean + fix CI errors
jsell-rh Jun 6, 2026
321c69d
fix(api-server): remove Credential.ProjectId from integration test
jsell-rh Jun 6, 2026
f13bc88
fix(api-server): address all remaining CodeRabbit findings
jsell-rh Jun 6, 2026
5bab7b7
lint
jsell-rh Jun 6, 2026
135af17
fix(e2e): self-contained JWT + RBAC enforcement setup in e2e test
jsell-rh Jun 6, 2026
15c0fcc
feat(api-server): replace service caller RBAC bypass with RoleBinding
jsell-rh Jun 6, 2026
f10507b
fix(e2e): curl timeout, session cleanup, CP ordering fix
jsell-rh Jun 6, 2026
e75de97
fix(api-server): close 3 security council findings
jsell-rh Jun 6, 2026
98b4c22
fix(api-server): gate service caller detection on verified JWT payload
jsell-rh Jun 7, 2026
4d9010a
fix(e2e): move session cleanup after Phase 19
jsell-rh Jun 7, 2026
3428df1
fix(e2e): always restart CP for fresh gRPC watch streams
jsell-rh Jun 7, 2026
5e3d19c
fix(api-server): init ordering bug prevented gRPC service caller dete…
jsell-rh Jun 7, 2026
478ad7f
fix(e2e): auto-reconnect port-forward on connection failure
jsell-rh Jun 7, 2026
39bacd9
Merge branch 'main' into jsell/feat/rbac-enforcement
mergify[bot] Jun 8, 2026
d4e07a5
Merge branch 'main' into jsell/feat/rbac-enforcement
mergify[bot] Jun 8, 2026
44e82bd
Merge branch 'main' into jsell/feat/rbac-enforcement
mergify[bot] Jun 8, 2026
20547eb
fix(e2e): port-forward readiness check fails when JWT is enabled
jsell-rh Jun 8, 2026
e9ce4c2
fix(e2e): wait for CP watch streams and retry transient errors
jsell-rh Jun 8, 2026
97ffeef
Merge branch 'jsell/feat/rbac-enforcement' of https://github.com/ambi…
jsell-rh Jun 8, 2026
c2a7e3b
fix(e2e): port-forward Keycloak in CI and harden cleanup trap
jsell-rh Jun 8, 2026
969a634
test(e2e): add failing test for F5 — owner can delete admin binding
jsell-rh Jun 8, 2026
eee4b99
fix(api-server): close F2, F3, F5 from security council review
jsell-rh Jun 8, 2026
6af2da5
style(api-server): gofmt sessions grpc_handler.go
jsell-rh Jun 8, 2026
251c01f
ci(e2e): build and deploy ambient-api-server from branch code
jsell-rh Jun 8, 2026
5b7a6fd
Merge branch 'main' into jsell/feat/rbac-enforcement
mergify[bot] Jun 8, 2026
0c3ed32
fix(ci): let test script handle RBAC setup instead of inline workflow
jsell-rh Jun 8, 2026
e7df51c
Merge branch 'jsell/feat/rbac-enforcement' of https://github.com/ambi…
jsell-rh Jun 8, 2026
e2e2604
fix(api-server): production env clears jwk-cert-file CLI flag
jsell-rh Jun 8, 2026
b92b839
debug(api-server): add RBAC middleware diagnostic log for CI 403
jsell-rh Jun 8, 2026
34b725b
fix(ci): always build api-server from branch code
jsell-rh Jun 8, 2026
4efbff2
Merge branch 'main' into jsell/feat/rbac-enforcement
mergify[bot] Jun 9, 2026
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
27 changes: 27 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,33 @@ jobs:
done
./scripts/run-tests.sh

- name: Run RBAC enforcement E2E tests
run: |
# Port-forward api-server for RBAC tests
kubectl port-forward -n ambient-code svc/ambient-api-server 13592:8000 &
APIPF=$!
sleep 2

# Enable JWT + RBAC enforcement on api-server
kubectl set env deployment/ambient-api-server -n ambient-code \
AMBIENT_ENV=production \
JWK_CERT_URL=http://keycloak-service:8080/realms/ambient-code/protocol/openid-connect/certs
kubectl get deployment ambient-api-server -n ambient-code -o json \
| sed 's/--enable-jwt=false/--enable-jwt=true/' \
| sed 's/--enable-authz=false/--enable-authz=true/' \
| kubectl apply -f -
kubectl rollout status deployment/ambient-api-server -n ambient-code --timeout=120s

# Restart port-forward after rollout
kill $APIPF 2>/dev/null || true
kubectl port-forward -n ambient-code svc/ambient-api-server 13592:8000 &
sleep 2

# Run RBAC e2e tests (Keycloak on NodePort 30090)
API_URL=http://localhost:13592/api/ambient/v1 \
KC_URL=http://localhost:30090 \
bash components/ambient-api-server/test/e2e/rbac_e2e_test.sh

- name: Upload test results
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ func (e *IntegrationTestingEnvImpl) OverrideConfig(c *config.ApplicationConfig)
}

func (e *IntegrationTestingEnvImpl) OverrideServices(s *pkgenv.Services) error {
s.SetService("RBACMiddleware", nil)
return nil
}

Expand All @@ -52,7 +51,7 @@ func (e *IntegrationTestingEnvImpl) Flags() map[string]string {
"api-base-url": "https://api.integration.openshift.com",
"enable-https": "false",
"enable-metrics-https": "false",
"enable-authz": "true",
"enable-authz": "false",
"debug": "false",
"enable-mock": "true",
"api-server-bindaddress": "localhost:0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func main() {
pkgcmd.NewMigrateCommand("ambient-api-server"),
pkgcmd.NewServeCommand(localapi.GetOpenAPISpec),
localcmd.NewEncryptCredentialsCommand(),
localcmd.NewSeedAdminCommand(),
)

if err := rootCmd.Execute(); err != nil {
Expand Down
82 changes: 82 additions & 0 deletions components/ambient-api-server/pkg/cmd/seed_admin.go
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
}
62 changes: 62 additions & 0 deletions components/ambient-api-server/pkg/rbac/context.go
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

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
}
194 changes: 194 additions & 0 deletions components/ambient-api-server/pkg/rbac/evaluator.go
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Binding lookup uses username against role_bindings.user_id

Line 44 filters rb.user_id with username, but role bindings are created with users.id (not username). This breaks the users↔role_bindings contract and can deny valid requests by returning zero bindings.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/ambient-api-server/pkg/rbac/evaluator.go` around lines 34 - 45,
The query in the g.Raw call filters role_bindings.rb.user_id using the variable
username, but role_bindings.user_id stores users.id; change the filter to use
the user's numeric ID or join to users to match username. Update the Raw SQL in
evaluator.go (the g.Raw(...) that scans into rows) to either: (a) accept and use
a userID variable and replace "WHERE rb.user_id = ?" with "WHERE rb.user_id = ?"
passing userID instead of username, or (b) JOIN users u ON u.id = rb.user_id and
replace the WHERE clause with "WHERE u.username = ?" so the existing username
parameter works; keep the rest of the selected columns and the Scan(&rows).
Ensure the parameter passed to g.Raw matches the chosen filter.

}

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
}
}
Loading
Loading