diff --git a/components/ambient-api-server/pkg/middleware/bearer_token_grpc.go b/components/ambient-api-server/pkg/middleware/bearer_token_grpc.go index afb11d86b..c82cdd9c1 100644 --- a/components/ambient-api-server/pkg/middleware/bearer_token_grpc.go +++ b/components/ambient-api-server/pkg/middleware/bearer_token_grpc.go @@ -3,7 +3,10 @@ package middleware import ( "context" "crypto/subtle" + "strings" + "github.com/golang-jwt/jwt/v4" + "github.com/openshift-online/rh-trex-ai/pkg/auth" "google.golang.org/grpc" "google.golang.org/grpc/metadata" ) @@ -25,6 +28,9 @@ func bearerTokenGRPCUnaryInterceptor(expectedToken string) grpc.UnaryServerInter if subtle.ConstantTimeCompare([]byte(token), []byte(expectedToken)) == 1 { return handler(withCallerType(ctx, CallerTypeService), req) } + if username := usernameFromJWT(token); username != "" { + return handler(auth.SetUsernameContext(ctx, username), req) + } } } } @@ -45,6 +51,10 @@ func bearerTokenGRPCStreamInterceptor(expectedToken string) grpc.StreamServerInt if subtle.ConstantTimeCompare([]byte(token), []byte(expectedToken)) == 1 { return handler(srv, &serviceCallerStream{ServerStream: ss, ctx: withCallerType(ss.Context(), CallerTypeService)}) } + if username := usernameFromJWT(token); username != "" { + ctx := auth.SetUsernameContext(ss.Context(), username) + return handler(srv, &serviceCallerStream{ServerStream: ss, ctx: ctx}) + } } } } @@ -53,6 +63,24 @@ func bearerTokenGRPCStreamInterceptor(expectedToken string) grpc.StreamServerInt } } +func usernameFromJWT(tokenString string) string { + p := jwt.NewParser() + token, _, err := p.ParseUnverified(tokenString, jwt.MapClaims{}) + if err != nil { + return "" + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "" + } + for _, key := range []string{"preferred_username", "username", "sub"} { + if v, _ := claims[key].(string); v != "" && !strings.Contains(v, ":") { + return v + } + } + return "" +} + type serviceCallerStream struct { grpc.ServerStream ctx context.Context diff --git a/components/ambient-api-server/plugins/credentials/migration.go b/components/ambient-api-server/plugins/credentials/migration.go index 54089b6f4..78cf7bbce 100644 --- a/components/ambient-api-server/plugins/credentials/migration.go +++ b/components/ambient-api-server/plugins/credentials/migration.go @@ -1,9 +1,12 @@ package credentials import ( + "encoding/json" + "gorm.io/gorm" "github.com/go-gormigrate/gormigrate/v2" + "github.com/openshift-online/rh-trex-ai/pkg/api" "github.com/openshift-online/rh-trex-ai/pkg/db" ) @@ -30,3 +33,67 @@ func migration() *gormigrate.Migration { }, } } + +func rolesMigration() *gormigrate.Migration { + type roleRow struct { + ID string + Name string + DisplayName string + Description string + Permissions string + BuiltIn bool + } + + seed := []struct { + name string + displayName string + description string + permissions []string + }{ + { + name: "credential:token-reader", + displayName: "Credential Token Reader", + description: "Retrieve the raw token value for a credential", + permissions: []string{"credential:token"}, + }, + { + name: "credential:reader", + displayName: "Credential Reader", + description: "Read credential metadata (name, provider, description)", + permissions: []string{"credential:read", "credential:list"}, + }, + } + + return &gormigrate.Migration{ + ID: "202603311216", + Migrate: func(tx *gorm.DB) error { + for _, r := range seed { + permsJSON, err := json.Marshal(r.permissions) + if err != nil { + return err + } + row := roleRow{ + ID: api.NewID(), + Name: r.name, + DisplayName: r.displayName, + Description: r.description, + Permissions: string(permsJSON), + BuiltIn: true, + } + if err := tx.Table("roles"). + Where("name = ?", r.name). + FirstOrCreate(&row).Error; err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + names := make([]string, len(seed)) + for i, r := range seed { + names[i] = r.name + } + return tx.Table("roles").Where("name IN ?", names).Delete(&roleRow{}).Error + }, + } +} diff --git a/components/ambient-api-server/plugins/credentials/plugin.go b/components/ambient-api-server/plugins/credentials/plugin.go index 8a5ec8270..2b2b849c7 100644 --- a/components/ambient-api-server/plugins/credentials/plugin.go +++ b/components/ambient-api-server/plugins/credentials/plugin.go @@ -82,4 +82,5 @@ func init() { presenters.RegisterKind(&Credential{}, "Credential") db.RegisterMigration(migration()) + db.RegisterMigration(rolesMigration()) } diff --git a/components/ambient-api-server/plugins/credentials/testmain_test.go b/components/ambient-api-server/plugins/credentials/testmain_test.go index db2f2f071..6c2db9408 100644 --- a/components/ambient-api-server/plugins/credentials/testmain_test.go +++ b/components/ambient-api-server/plugins/credentials/testmain_test.go @@ -9,6 +9,18 @@ import ( "github.com/golang/glog" "github.com/ambient-code/platform/components/ambient-api-server/test" + + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/agents" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/inbox" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/projectSettings" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/projects" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/rbac" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/roleBindings" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/roles" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/sessions" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/users" + _ "github.com/openshift-online/rh-trex-ai/plugins/events" + _ "github.com/openshift-online/rh-trex-ai/plugins/generic" ) func TestMain(m *testing.M) { diff --git a/components/ambient-api-server/plugins/sessions/grpc_handler.go b/components/ambient-api-server/plugins/sessions/grpc_handler.go index f7c642862..ad7454a42 100644 --- a/components/ambient-api-server/plugins/sessions/grpc_handler.go +++ b/components/ambient-api-server/plugins/sessions/grpc_handler.go @@ -288,7 +288,10 @@ func (h *sessionGRPCHandler) WatchSessionMessages(req *pb.WatchSessionMessagesRe if !middleware.IsServiceCaller(ctx) { username := auth.GetUsernameFromContext(ctx) - if username != "" && (h.grpcServiceAccount == "" || username != h.grpcServiceAccount) { + if username == "" { + return status.Error(codes.PermissionDenied, "not authorized to watch this session") + } + if h.grpcServiceAccount == "" || username != h.grpcServiceAccount { session, svcErr := h.service.Get(ctx, req.GetSessionId()) if svcErr != nil { return grpcutil.ServiceErrorToGRPC(svcErr) diff --git a/components/ambient-api-server/plugins/sessions/handler.go b/components/ambient-api-server/plugins/sessions/handler.go index 68ceca47a..996fafdb5 100644 --- a/components/ambient-api-server/plugins/sessions/handler.go +++ b/components/ambient-api-server/plugins/sessions/handler.go @@ -5,6 +5,7 @@ import ( "io" "net" "net/http" + "strings" "time" "github.com/golang/glog" @@ -299,7 +300,7 @@ func (h sessionHandler) StreamRunnerEvents(w http.ResponseWriter, r *http.Reques runnerURL := fmt.Sprintf( "http://session-%s.%s.svc.cluster.local:8001/events/%s", - *session.KubeCrName, *session.KubeNamespace, *session.KubeCrName, + strings.ToLower(*session.KubeCrName), *session.KubeNamespace, *session.KubeCrName, ) req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, runnerURL, nil) diff --git a/components/ambient-cli/README.md b/components/ambient-cli/README.md index fefec5847..fb11903f0 100644 --- a/components/ambient-cli/README.md +++ b/components/ambient-cli/README.md @@ -21,91 +21,231 @@ This produces an `acpctl` binary in the current directory with embedded version ```bash # With a token and API server URL -./acpctl login --token --url http://localhost:8000 --project myproject +acpctl login --token + +# Skip TLS verification (e.g. local Kind cluster) +acpctl login --token --insecure-skip-tls-verify + +# Use RH SSO +acpctl login --use-auth-code --url https://ambient-api-server-ambient-code--ambient-s0.apps.int.spoke.dev.us-east-1.aws.paas.redhat.com # Verify -./acpctl whoami +acpctl whoami +# User: service-account-bob +# Project: myproject ``` ### 2. Configure defaults ```bash # Set or change the default project -./acpctl config set project myproject +acpctl config set project myproject + +# Switch active project context (shorthand) +acpctl project myproject + +# Show the currently active project +acpctl project current # View current settings -./acpctl config get api_url -./acpctl config get project +acpctl config get api_url +acpctl config get project ``` ### 3. List resources ```bash -# List sessions (table format) -./acpctl get sessions +# Sessions +acpctl get sessions +acpctl get sessions -o json -# List projects -./acpctl get projects +# Single session by ID +acpctl get session +acpctl get session -o json -# JSON output -./acpctl get sessions -o json +# Projects +acpctl get projects + +# Agents +acpctl get agents +acpctl get agents -o json -# Single resource by ID -./acpctl get session +# Credentials +acpctl get credentials +acpctl get credentials -o json + +# Roles +acpctl get roles +acpctl get roles -o json ``` ### 4. Create resources ```bash # Create a project -./acpctl create project --name my-project --display-name "My Project" +acpctl create project --name my-project --display-name "My Project" --description "Demo project" # Create a session -./acpctl create session --name fix-bug-123 \ +acpctl create session --name fix-bug-123 \ --prompt "Fix the null pointer in handler.go" \ --repo-url https://github.com/org/repo \ --model sonnet # Create with all options -./acpctl create session --name refactor-auth \ +acpctl create session --name refactor-auth \ --prompt "Refactor the auth middleware" \ --model sonnet \ --max-tokens 4000 \ --temperature 0.7 \ --timeout 3600 + +# Create an agent +acpctl agent create \ + --project-id my-project \ + --name my-agent \ + --prompt "You are a GitHub automation agent." + +# Create a role binding (bind a credential role to an agent) +acpctl create role-binding \ + --user-id \ + --role-id \ + --scope agent \ + --scope-id ``` -### 5. Session lifecycle +### 5. Apply declarative manifests + +`acpctl apply` creates or updates resources from YAML files. Token values can be +injected via environment variables referenced in the manifest. + +```bash +# Apply a credential manifest +cat > credential.yaml <<'EOF' +kind: Credential +name: my-github-pat +provider: github +token: $GITHUB_TOKEN +description: GitHub PAT for CI +EOF + +GITHUB_TOKEN="ghp_..." acpctl apply -f credential.yaml +``` + +Supported `kind` values: `Credential` (additional kinds vary by deployment). + +### 6. Agent sessions + +```bash +# Start a session for a named agent with an initial prompt +acpctl agent start \ + --project-id \ + --prompt "Open a test issue in org/repo" + +# Start and capture the session ID +SESSION_JSON=$(acpctl agent start my-agent --project-id my-project --prompt "..." -o json) +SESSION_ID=$(echo "$SESSION_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +``` + +### 7. Session lifecycle ```bash # Start a session -./acpctl start +acpctl start # Stop a session -./acpctl stop +acpctl stop +``` + +### 8. Session messages + +```bash +# List all messages for a session (table format) +acpctl session messages + +# JSON output +acpctl session messages -o json + +# Stream new messages live (follow mode) +acpctl session messages -f + +# Stream only messages after a known sequence number +acpctl session messages -f --after 42 + +# Send a user message to a running session (multi-turn) +acpctl session send "Please also update the test file." ``` -### 6. Inspect resources +### 9. Inspect resources ```bash -# Full JSON detail of a session -./acpctl describe session +# Full detail of a session +acpctl describe session -# Full JSON detail of a project -./acpctl describe project +# Full detail of a project +acpctl describe project ``` -### 7. Delete resources +### 10. Delete resources ```bash -./acpctl delete project -./acpctl delete project-settings +acpctl delete session -y +acpctl delete project -y +acpctl delete project-settings +acpctl credential delete --confirm ``` -### 8. Log out +### 11. Log out ```bash -./acpctl logout +acpctl logout +``` + +## Credentials + +Credentials store secrets (e.g. GitHub PATs, API keys) that are injected into +agent sessions at runtime. The runner retrieves the raw token via the +credentials API, so the secret is never embedded in session configuration. + +```bash +# List credentials +acpctl get credentials + +# Create via apply (token injected from env var — never passed as a flag) +GITHUB_TOKEN="ghp_..." acpctl apply -f credential.yaml + +# Delete +acpctl credential delete --confirm +``` + +### Role bindings + +Access to credentials is controlled by role bindings. The relevant roles are: + +| Role | Permission | +|---|---| +| `credential:token-reader` | Retrieve the raw credential token via `GET /credentials/{id}/token` | +| `credential:reader` | Read credential metadata (name, provider, description) | + +```bash +# Look up a role ID +ROLE_ID=$(acpctl get roles -o json | python3 -c " +import sys, json +data = json.load(sys.stdin) +items = data.get('items', []) if isinstance(data, dict) else data +for r in items: + if r.get('name') == 'credential:token-reader': + print(r['id']); break +") + +# Get your user ID +MY_USER_ID=$(acpctl whoami | awk '/^User:/{print $2}') + +# Bind the role to an agent (agent can now retrieve the token) +acpctl create role-binding \ + --user-id "${MY_USER_ID}" \ + --role-id "${ROLE_ID}" \ + --scope agent \ + --scope-id ``` ## Try It Now (No Server Required) @@ -122,12 +262,12 @@ make build ./acpctl create --help # Login and config flow -./acpctl login --token test-token --url http://localhost:8000 --project demo +./acpctl login http://localhost:8000 --token test-token ./acpctl whoami ./acpctl config get api_url ./acpctl config get project ./acpctl config set project other-project -./acpctl config get project +./acpctl project current # Shell completion ./acpctl completion bash diff --git a/components/ambient-control-plane/internal/config/config.go b/components/ambient-control-plane/internal/config/config.go index 900e434a9..cfd0852d1 100644 --- a/components/ambient-control-plane/internal/config/config.go +++ b/components/ambient-control-plane/internal/config/config.go @@ -57,7 +57,7 @@ func Load() (*ControlPlaneConfig, error) { Reconcilers: parseReconcilers(envOrDefault("RECONCILERS", "tally,kube")), RunnerImage: envOrDefault("RUNNER_IMAGE", "quay.io/ambient_code/vteam_claude_runner:latest"), RunnerGRPCUseTLS: os.Getenv("AMBIENT_GRPC_USE_TLS") == "true", - BackendURL: envOrDefault("BACKEND_API_URL", "http://backend-service.ambient-code.svc:8080/api"), + BackendURL: envOrDefault("BACKEND_API_URL", envOrDefault("AMBIENT_API_SERVER_URL", "http://localhost:8000")), Namespace: envOrDefault("NAMESPACE", "ambient-code"), AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"), VertexEnabled: os.Getenv("USE_VERTEX") == "1" || os.Getenv("USE_VERTEX") == "true", diff --git a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go index 93000b263..3a348ebc8 100644 --- a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go +++ b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go @@ -24,7 +24,7 @@ const ( runnerTokenFileName = "bot-token" runnerTokenVolumeKey = "api-token" runnerTokenVolumeName = "runner-token" - runnerTokenRefreshEvery = 10 * time.Minute + runnerTokenRefreshEvery = 4 * time.Minute ) type KubeReconcilerConfig struct { @@ -904,6 +904,7 @@ func (r *SimpleKubeReconciler) refreshRunnerToken(ctx context.Context, namespace func (r *SimpleKubeReconciler) StartTokenRefreshLoop(ctx context.Context) { go func() { + r.refreshAllRunningTokens(ctx) ticker := time.NewTicker(runnerTokenRefreshEvery) defer ticker.Stop() for { @@ -918,29 +919,47 @@ func (r *SimpleKubeReconciler) StartTokenRefreshLoop(ctx context.Context) { } func (r *SimpleKubeReconciler) refreshAllRunningTokens(ctx context.Context) { - sdk, err := r.factory.ForProject(ctx, "") - if err != nil { - r.logger.Warn().Err(err).Msg("token refresh loop: failed to get SDK client") - return - } - - opts := &types.ListOptions{Page: 1, Size: 100, Search: "phase = 'Running'"} + projectOpts := &types.ListOptions{Page: 1, Size: 100} for { - list, err := sdk.Sessions().List(ctx, opts) + projectSDK, err := r.factory.ForProject(ctx, "_") if err != nil { - r.logger.Warn().Err(err).Int("page", opts.Page).Msg("token refresh loop: failed to list running sessions") + r.logger.Warn().Err(err).Msg("token refresh loop: failed to get SDK client") return } - for i := range list.Items { - session := list.Items[i] - namespace := r.namespaceForSession(session) - if err := r.refreshRunnerToken(ctx, namespace, session.ID); err != nil { - r.logger.Warn().Err(err).Str("session_id", session.ID).Str("namespace", namespace).Msg("token refresh loop: failed to refresh token") + projectList, err := projectSDK.Projects().List(ctx, projectOpts) + if err != nil { + r.logger.Warn().Err(err).Int("page", projectOpts.Page).Msg("token refresh loop: failed to list projects") + return + } + for _, project := range projectList.Items { + sdk, err := r.factory.ForProject(ctx, project.ID) + if err != nil { + r.logger.Warn().Err(err).Str("project_id", project.ID).Msg("token refresh loop: failed to get SDK client for project") + continue + } + sessionOpts := &types.ListOptions{Page: 1, Size: 100, Search: "phase = 'Running'"} + for { + list, err := sdk.Sessions().List(ctx, sessionOpts) + if err != nil { + r.logger.Warn().Err(err).Str("project_id", project.ID).Int("page", sessionOpts.Page).Msg("token refresh loop: failed to list running sessions") + break + } + for i := range list.Items { + session := list.Items[i] + namespace := r.namespaceForSession(session) + if err := r.refreshRunnerToken(ctx, namespace, session.ID); err != nil { + r.logger.Warn().Err(err).Str("session_id", session.ID).Str("namespace", namespace).Msg("token refresh loop: failed to refresh token") + } + } + if len(list.Items) == 0 || list.Total <= sessionOpts.Page*sessionOpts.Size { + break + } + sessionOpts.Page++ } } - if len(list.Items) == 0 || list.Total <= opts.Page*opts.Size { + if len(projectList.Items) == 0 || projectList.Total <= projectOpts.Page*projectOpts.Size { break } - opts.Page++ + projectOpts.Page++ } }