From 0cf5be4c17739f77995e87cc91971661cac95bfd Mon Sep 17 00:00:00 2001 From: Chris Doan Date: Sat, 8 Aug 2026 17:29:06 -0700 Subject: [PATCH 1/2] fix Cedar schema validation and ARN normalization for non-privileged accounts - Remove invalid additionalAttributes from Cedar schema (AVP PutSchema rejection) - Add adminArn field to account provisioning to bootstrap initial admin - Require adminArn for non-privileged accounts to prevent admin deadlock - Export and use NormalizeAssumedRoleARN to match STS assumed-role ARNs against stored IAM role ARNs in both IsAdmin and Cedar buildAVPRequest - Add unit tests for ARN normalization and accounts handler validation - Add Cedar policy management docs and cedar.sh helper script Co-Authored-By: Claude Opus 4.6 --- docs/api/cedar-policies.md | 278 ++++++++++++++++++ platform-api/pkg/authz/authz.go | 6 +- .../pkg/authz/schema/rosa.cedarschema.json | 12 +- platform-api/pkg/authz/store/admins.go | 59 +++- platform-api/pkg/authz/store/admins_test.go | 66 +++++ platform-api/pkg/handlers/accounts.go | 18 +- platform-api/pkg/handlers/accounts_test.go | 223 ++++++++++++++ scripts/cedar.sh | 90 ++++++ 8 files changed, 729 insertions(+), 23 deletions(-) create mode 100644 docs/api/cedar-policies.md create mode 100644 platform-api/pkg/authz/store/admins_test.go create mode 100644 platform-api/pkg/handlers/accounts_test.go create mode 100755 scripts/cedar.sh diff --git a/docs/api/cedar-policies.md b/docs/api/cedar-policies.md new file mode 100644 index 00000000..8f36f858 --- /dev/null +++ b/docs/api/cedar-policies.md @@ -0,0 +1,278 @@ +# Cedar Policy Management + +This document describes how to provision a non-privileged account with Cedar/AVP authorization and manage fine-grained access policies for clusters, nodepools, and other ROSA resources. + +## Overview + +ROSA Hyperfleet uses [Amazon Verified Permissions (AVP)](https://docs.aws.amazon.com/verifiedpermissions/latest/userguide/what-is-avp.html) with Cedar policies for fine-grained authorization. Each non-privileged account gets its own AVP policy store with the ROSA Cedar schema. Admins for the account manage Cedar policies that control what actions principals (IAM roles) can perform. + +## Account Types + + +| Type | Example | Cedar/AVP | Admin check | Use case | +| -------------- | -------------- | -------------------- | ---------------------------------- | ----------------------------------------- | +| Privileged | `599476212575` | Bypassed entirely | Bypassed | Platform operations, account provisioning | +| Non-privileged | `754250776154` | Enforced per-request | Enforced via DynamoDB admins table | Customer workloads | + + + + +## Authorization Flow + +``` +Request + -> SigV4 Auth (API Gateway) + -> Identity Middleware (extracts account ID, caller ARN) + -> CheckPrivileged (sets privileged flag) + -> RequireProvisioned (verifies account has a policy store) + -> RequireAdmin (for authz management endpoints only) + -> Cedar/AVP IsAuthorized (for resource endpoints) + -> Handler +``` + +- **Privileged accounts** bypass `RequireProvisioned`, `RequireAdmin`, and Cedar evaluation. +- **Non-privileged admins** bypass Cedar evaluation but must pass `RequireAdmin` to access authz management endpoints. +- **Non-privileged non-admins** are evaluated against Cedar policies in AVP for every resource operation. + + + +## End-to-End Setup Sequence + + + +### Step 1: Provision the account (privileged caller) + +A privileged account creates the non-privileged account. The `adminArn` field is **required** for non-privileged accounts to bootstrap the first admin and avoid a deadlock (no admin = can't add admins). + +```bash +awscurl --service execute-api --region us-east-1 \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "accountId": "754250776154", + "privileged": false, + "adminArn": "arn:aws:iam::754250776154:role/bff-sigv4-proxy-role" + }' \ + "${API_URL}/api/v0/accounts" +``` + +This creates: + +- An account record in DynamoDB +- An AVP policy store for the account +- Uploads the ROSA Cedar schema to the policy store +- Adds `adminArn` as the initial admin in the admins table + +> **Why** `adminArn` **instead of auto-capturing the caller?** The caller is from the privileged account (599476212575), not the account being created (754250776154). There is no way to infer the correct admin ARN from the cross-account request context. + + + +```bash +awscurl --service execute-api --region us-east-1 \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "accountId": "754250776154", + "privileged": false, + "adminArn": "arn:aws:sts::754250776154:assumed-role/OrganizationAccountAccessRole" + }' \ + "${API_URL}/api/v0/accounts" + + +``` + + + +### Step 2: Create a Cedar policy (admin caller) + +The bootstrapped admin creates Cedar policy templates. Each template defines what actions are allowed on which resource types. + +```bash +awscurl --service execute-api --region us-east-1 \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-Amz-Account-Id: 754250776154" \ + -d '{ + "name": "clusters-read-only", + "description": "Read-only access to clusters", + "policy": "permit(principal == ?principal, action in [ROSA::Action::\"ListClusters\", ROSA::Action::\"DescribeCluster\"], resource);" + }' \ + "${API_URL}/api/v0/authz/policies" +``` + + + +### Step 3: Attach the policy to a principal + +Bind the policy template to a concrete IAM role ARN. This creates a template-linked policy in AVP. + +```bash +awscurl --service execute-api --region us-east-1 \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-Amz-Account-Id: 754250776154" \ + -d '{ + "policyId": "", + "targetType": "user", + "targetId": "arn:aws:iam::754250776154:role/some-app-role" + }' \ + "${API_URL}/api/v0/authz/attachments" +``` + + + +### Step 4: (Optional) Add more admins + +```bash +awscurl --service execute-api --region us-east-1 \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-Amz-Account-Id: 754250776154" \ + -d '{"principalArn": "arn:aws:iam::754250776154:role/another-admin-role"}' \ + "${API_URL}/api/v0/authz/admins" +``` + + + +### Step 5: (Optional) Remove admin access + +If the initial admin role should be governed solely by Cedar policies rather than having full admin access: + +```bash +awscurl --service execute-api --region us-east-1 \ + -X DELETE \ + -H "X-Amz-Account-Id: 754250776154" \ + "${API_URL}/api/v0/authz/admins/arn:aws:iam::754250776154:role/bff-sigv4-proxy-role" +``` + + + +## Flow Summary + + +| Step | Who calls | Endpoint | What happens | +| ----------------------------- | ------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------- | +| 1. Provision account | Privileged (599476212575) | `POST /api/v0/accounts` | Creates account, AVP policy store, uploads Cedar schema, adds `adminArn` as initial admin | +| 2. Create policy | Admin (754250776154) | `POST /api/v0/authz/policies` | Creates Cedar policy template in AVP | +| 3. Attach policy | Admin (754250776154) | `POST /api/v0/authz/attachments` | Binds policy to a principal ARN via template-linked policy | +| 4. (Optional) Add more admins | Admin (754250776154) | `POST /api/v0/authz/admins` | Grants another role admin access to manage policies | +| 5. (Optional) Remove admin | Admin (754250776154) | `DELETE /api/v0/authz/admins/{arn}` | Removes admin; role is then governed only by Cedar policies | + + + + +## Key Concepts + + +| Concept | Details | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `adminArn` | Required for non-privileged accounts; bootstraps the first admin so the account is not deadlocked | +| Why not auto-capture caller? | Caller is cross-account (privileged 599476212575), not the account owner (754250776154) | +| Admin vs Cedar | Admins bypass Cedar and have full authz CRUD; Cedar policies enforce fine-grained access for non-admins | +| ARN normalization | STS assumed-role ARNs (`arn:aws:sts::ACCT:assumed-role/R/session`) are normalized to IAM ARNs (`arn:aws:iam::ACCT:role/R`) for both admin matching and Cedar evaluation | +| Policy template syntax | Must use `principal == ?principal` (not bare `?principal`); one `permit`/`forbid` statement per template | +| `X-Amz-Account-Id` | Injected by API Gateway from SigV4 credentials; identifies the caller's account, not a target account | + + + + +## Cedar Policy Syntax + + + +### Template slots + +Cedar policy templates use `?principal` and `?resource` as placeholders. They must appear in a scope constraint: + +```cedar +permit( + principal == ?principal, + action == ROSA::Action::"ListClusters", + resource +); +``` + + + +### Multiple actions + +Use `action in [...]` to grant multiple actions in a single template: + +```cedar +permit( + principal == ?principal, + action in [ROSA::Action::"ListClusters", ROSA::Action::"DescribeCluster"], + resource +); +``` + + + +### One statement per template + +AVP requires exactly **one** `permit` or `forbid` statement per policy template. To grant unrelated action sets, create separate policy templates. + +## Available Cedar Actions + + + +### Cluster actions + +- `ROSA::Action::"CreateCluster"` +- `ROSA::Action::"DeleteCluster"` +- `ROSA::Action::"DescribeCluster"` +- `ROSA::Action::"ListClusters"` +- `ROSA::Action::"UpdateCluster"` +- `ROSA::Action::"UpdateClusterConfig"` +- `ROSA::Action::"UpdateClusterVersion"` + + + +### NodePool actions + +- `ROSA::Action::"CreateNodePool"` +- `ROSA::Action::"DeleteNodePool"` +- `ROSA::Action::"DescribeNodePool"` +- `ROSA::Action::"ListNodePools"` +- `ROSA::Action::"UpdateNodePool"` +- `ROSA::Action::"ScaleNodePool"` + + + +### AccessEntry actions + +- `ROSA::Action::"CreateAccessEntry"` +- `ROSA::Action::"DeleteAccessEntry"` +- `ROSA::Action::"DescribeAccessEntry"` +- `ROSA::Action::"ListAccessEntries"` +- `ROSA::Action::"UpdateAccessEntry"` + + + +### Tagging actions + +- `ROSA::Action::"TagResource"` +- `ROSA::Action::"UntagResource"` +- `ROSA::Action::"ListTagsForResource"` + + + +### Other actions + +- `ROSA::Action::"ListAccessPolicies"` + + + +## Troubleshooting + + +| Error | Cause | Fix | +| -------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `ValidationException: Invalid input` on `PutSchema` | Cedar schema contains `additionalAttributes` field | Remove `additionalAttributes` from all entity types in `rosa.cedarschema.json` | +| `ValidationException: Invalid input` on `CreatePolicyTemplate` | Invalid Cedar syntax (bare `?principal`, multiple statements) | Use `principal == ?principal` and one statement per template | +| `AccessDeniedException` on `CreatePolicyTemplate` | Platform API pod role missing IAM permissions | Add `verifiedpermissions:CreatePolicyTemplate` and related actions to the IAM policy | +| `not-admin` error on authz endpoints | Caller ARN not in admins table for the account | Add the caller as admin, or check ARN normalization (STS vs IAM form) | +| `account-not-provisioned` | Account not created via `POST /api/v0/accounts` | Provision the account first with a privileged caller | +| Cedar policy allows but request denied | STS assumed-role ARN doesn't match IAM ARN in policy attachment | Ensure ARN normalization is enabled in `buildAVPRequest` | + + diff --git a/platform-api/pkg/authz/authz.go b/platform-api/pkg/authz/authz.go index ba0d87b6..64bf9168 100644 --- a/platform-api/pkg/authz/authz.go +++ b/platform-api/pkg/authz/authz.go @@ -192,10 +192,12 @@ func (a *authorizerImpl) Authorize(ctx context.Context, req *AuthzRequest) (bool // buildAVPRequest creates the AVP IsAuthorized request func (a *authorizerImpl) buildAVPRequest(req *AuthzRequest, groups []string, policyStoreID string) *verifiedpermissions.IsAuthorizedInput { - // Build principal + // Build principal — normalize STS assumed-role ARNs to IAM role ARNs + // so they match the IAM ARN used when attaching policies. + principalARN := store.NormalizeAssumedRoleARN(req.CallerARN) principal := &avptypes.EntityIdentifier{ EntityType: aws.String("ROSA::Principal"), - EntityId: aws.String(req.CallerARN), + EntityId: aws.String(principalARN), } // Build action diff --git a/platform-api/pkg/authz/schema/rosa.cedarschema.json b/platform-api/pkg/authz/schema/rosa.cedarschema.json index 050425b5..6e458282 100644 --- a/platform-api/pkg/authz/schema/rosa.cedarschema.json +++ b/platform-api/pkg/authz/schema/rosa.cedarschema.json @@ -19,8 +19,7 @@ "attributes": { "tags": { "type": "Record", - "attributes": {}, - "additionalAttributes": true + "attributes": {} } } } @@ -38,8 +37,7 @@ }, "tags": { "type": "Record", - "attributes": {}, - "additionalAttributes": true + "attributes": {} } } } @@ -54,8 +52,7 @@ }, "tags": { "type": "Record", - "attributes": {}, - "additionalAttributes": true + "attributes": {} } } } @@ -73,8 +70,7 @@ }, "tags": { "type": "Record", - "attributes": {}, - "additionalAttributes": true + "attributes": {} } } } diff --git a/platform-api/pkg/authz/store/admins.go b/platform-api/pkg/authz/store/admins.go index d11c1d72..dbf62806 100644 --- a/platform-api/pkg/authz/store/admins.go +++ b/platform-api/pkg/authz/store/admins.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" @@ -82,21 +83,55 @@ func (s *AdminStore) Remove(ctx context.Context, accountID, principalARN string) return nil } -// IsAdmin checks if a principal is an admin for an account +// IsAdmin checks if a principal is an admin for an account. +// It normalizes assumed-role STS ARNs to their IAM role ARN so that +// arn:aws:sts::ACCT:assumed-role/RoleName/session matches a stored +// arn:aws:iam::ACCT:role/RoleName entry. func (s *AdminStore) IsAdmin(ctx context.Context, accountID, principalARN string) (bool, error) { - result, err := s.dynamoClient.GetItem(ctx, &dynamodb.GetItemInput{ - TableName: aws.String(s.tableName), - Key: map[string]types.AttributeValue{ - "accountId": &types.AttributeValueMemberS{Value: accountID}, - "principalArn": &types.AttributeValueMemberS{Value: principalARN}, - }, - ProjectionExpression: aws.String("accountId"), - }) - if err != nil { - return false, fmt.Errorf("failed to check admin status: %w", err) + arnsToCheck := []string{principalARN} + if normalized := NormalizeAssumedRoleARN(principalARN); normalized != principalARN { + arnsToCheck = append(arnsToCheck, normalized) + } + + for _, arn := range arnsToCheck { + result, err := s.dynamoClient.GetItem(ctx, &dynamodb.GetItemInput{ + TableName: aws.String(s.tableName), + Key: map[string]types.AttributeValue{ + "accountId": &types.AttributeValueMemberS{Value: accountID}, + "principalArn": &types.AttributeValueMemberS{Value: arn}, + }, + ProjectionExpression: aws.String("accountId"), + }) + if err != nil { + return false, fmt.Errorf("failed to check admin status: %w", err) + } + if result.Item != nil { + return true, nil + } } - return result.Item != nil, nil + return false, nil +} + +// NormalizeAssumedRoleARN converts an STS assumed-role ARN to its IAM role ARN. +// arn:aws:sts::123456:assumed-role/MyRole/session -> arn:aws:iam::123456:role/MyRole +func NormalizeAssumedRoleARN(arn string) string { + if !strings.Contains(arn, ":assumed-role/") { + return arn + } + parts := strings.SplitN(arn, ":", 6) + if len(parts) < 6 { + return arn + } + resource := parts[5] + if !strings.HasPrefix(resource, "assumed-role/") { + return arn + } + segments := strings.SplitN(strings.TrimPrefix(resource, "assumed-role/"), "/", 2) + roleName := segments[0] + parts[2] = "iam" + parts[5] = "role/" + roleName + return strings.Join(parts, ":") } // List returns all admins for an account diff --git a/platform-api/pkg/authz/store/admins_test.go b/platform-api/pkg/authz/store/admins_test.go new file mode 100644 index 00000000..238ee109 --- /dev/null +++ b/platform-api/pkg/authz/store/admins_test.go @@ -0,0 +1,66 @@ +package store + +import "testing" + +func TestNormalizeAssumedRoleARN(t *testing.T) { + tests := []struct { + name string + arn string + want string + }{ + { + name: "STS assumed-role ARN is normalized to IAM role ARN", + arn: "arn:aws:sts::123456789012:assumed-role/MyRole/session-name", + want: "arn:aws:iam::123456789012:role/MyRole", + }, + { + name: "IAM role ARN is returned unchanged", + arn: "arn:aws:iam::123456789012:role/MyRole", + want: "arn:aws:iam::123456789012:role/MyRole", + }, + { + name: "IAM user ARN is returned unchanged", + arn: "arn:aws:iam::123456789012:user/MyUser", + want: "arn:aws:iam::123456789012:user/MyUser", + }, + { + name: "session name with slashes is handled", + arn: "arn:aws:sts::123456789012:assumed-role/MyRole/session/with/slashes", + want: "arn:aws:iam::123456789012:role/MyRole", + }, + { + name: "empty string is returned unchanged", + arn: "", + want: "", + }, + { + name: "malformed ARN with too few parts is returned unchanged", + arn: "arn:aws:sts", + want: "arn:aws:sts", + }, + { + name: "assumed-role without session suffix", + arn: "arn:aws:sts::123456789012:assumed-role/MyRole", + want: "arn:aws:iam::123456789012:role/MyRole", + }, + { + name: "real-world EKS pod identity ARN", + arn: "arn:aws:sts::599476212575:assumed-role/eph-b1fe3c6f-regional-authz-platform-api/eks-eph-b1fe3c-platform-a-96beb35e", + want: "arn:aws:iam::599476212575:role/eph-b1fe3c6f-regional-authz-platform-api", + }, + { + name: "BFF proxy role with session", + arn: "arn:aws:sts::754250776154:assumed-role/bff-sigv4-proxy-role/BFF-Session-1786164739", + want: "arn:aws:iam::754250776154:role/bff-sigv4-proxy-role", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NormalizeAssumedRoleARN(tt.arn) + if got != tt.want { + t.Errorf("NormalizeAssumedRoleARN(%q) = %q, want %q", tt.arn, got, tt.want) + } + }) + } +} diff --git a/platform-api/pkg/handlers/accounts.go b/platform-api/pkg/handlers/accounts.go index cd1e52da..8f416eba 100644 --- a/platform-api/pkg/handlers/accounts.go +++ b/platform-api/pkg/handlers/accounts.go @@ -30,6 +30,7 @@ func NewAccountsHandler(authorizer authz.Service, logger *slog.Logger) *Accounts type EnableAccountRequest struct { AccountID string `json:"accountId"` Privileged bool `json:"privileged"` + AdminArn string `json:"adminArn,omitempty"` } // AccountResponse is the response for account operations @@ -67,6 +68,11 @@ func (h *AccountsHandler) Create(w http.ResponseWriter, r *http.Request) { return } + if !req.Privileged && req.AdminArn == "" { + h.writeError(w, http.StatusBadRequest, "missing-admin-arn", "adminArn is required for non-privileged accounts") + return + } + // Check if account already exists existing, err := h.authorizer.GetAccount(ctx, req.AccountID) if err != nil { @@ -88,7 +94,17 @@ func (h *AccountsHandler) Create(w http.ResponseWriter, r *http.Request) { h.logger.Info("account enabled", "account_id", redact(req.AccountID), "privileged", req.Privileged) - if err := api.Write(w, http.StatusCreated, AccountResponse{ + if req.AdminArn != "" && !req.Privileged { + if err := h.authorizer.AddAdmin(ctx, req.AccountID, req.AdminArn, callerARN); err != nil { + h.logger.Error("failed to add initial admin", "error", err, "account_id", req.AccountID, "admin_arn", req.AdminArn) + } else { + h.logger.Info("initial admin added", "account_id", req.AccountID, "admin_arn", req.AdminArn) + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(AccountResponse{ Kind: "Account", AccountID: account.AccountID, PolicyStoreID: account.PolicyStoreID, diff --git a/platform-api/pkg/handlers/accounts_test.go b/platform-api/pkg/handlers/accounts_test.go new file mode 100644 index 00000000..071b485d --- /dev/null +++ b/platform-api/pkg/handlers/accounts_test.go @@ -0,0 +1,223 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/authz" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/authz/store" + "github.com/openshift-online/rosa-hyperfleet-api/platform-api/pkg/middleware" +) + +type mockAuthzService struct { + enableAccountFn func(ctx context.Context, accountID, createdBy string, isPrivileged bool) (*store.Account, error) + getAccountFn func(ctx context.Context, accountID string) (*store.Account, error) + addAdminFn func(ctx context.Context, accountID, principalARN, createdBy string) error +} + +func (m *mockAuthzService) EnableAccount(ctx context.Context, accountID, createdBy string, isPrivileged bool) (*store.Account, error) { + if m.enableAccountFn != nil { + return m.enableAccountFn(ctx, accountID, createdBy, isPrivileged) + } + return &store.Account{AccountID: accountID, Privileged: isPrivileged, CreatedBy: createdBy}, nil +} + +func (m *mockAuthzService) GetAccount(ctx context.Context, accountID string) (*store.Account, error) { + if m.getAccountFn != nil { + return m.getAccountFn(ctx, accountID) + } + return nil, nil +} + +func (m *mockAuthzService) AddAdmin(ctx context.Context, accountID, principalARN, createdBy string) error { + if m.addAdminFn != nil { + return m.addAdminFn(ctx, accountID, principalARN, createdBy) + } + return nil +} + +func (m *mockAuthzService) DisableAccount(ctx context.Context, accountID string) error { return nil } +func (m *mockAuthzService) ListAccounts(ctx context.Context) ([]*store.Account, error) { + return nil, nil +} +func (m *mockAuthzService) RemoveAdmin(ctx context.Context, accountID, principalARN string) error { + return nil +} +func (m *mockAuthzService) ListAdmins(ctx context.Context, accountID string) ([]string, error) { + return nil, nil +} +func (m *mockAuthzService) CreateGroup(ctx context.Context, accountID, name, description string) (*store.Group, error) { + return nil, nil +} +func (m *mockAuthzService) GetGroup(ctx context.Context, accountID, groupID string) (*store.Group, error) { + return nil, nil +} +func (m *mockAuthzService) DeleteGroup(ctx context.Context, accountID, groupID string) error { + return nil +} +func (m *mockAuthzService) ListGroups(ctx context.Context, accountID string) ([]*store.Group, error) { + return nil, nil +} +func (m *mockAuthzService) AddGroupMember(ctx context.Context, accountID, groupID, memberARN string) error { + return nil +} +func (m *mockAuthzService) RemoveGroupMember(ctx context.Context, accountID, groupID, memberARN string) error { + return nil +} +func (m *mockAuthzService) ListGroupMembers(ctx context.Context, accountID, groupID string) ([]string, error) { + return nil, nil +} +func (m *mockAuthzService) GetUserGroups(ctx context.Context, accountID, memberARN string) ([]string, error) { + return nil, nil +} +func (m *mockAuthzService) CreatePolicy(ctx context.Context, accountID, name, description, cedarPolicy string) (*store.Policy, error) { + return nil, nil +} +func (m *mockAuthzService) GetPolicy(ctx context.Context, accountID, policyID string) (*store.Policy, error) { + return nil, nil +} +func (m *mockAuthzService) UpdatePolicy(ctx context.Context, accountID, policyID, name, description, cedarPolicy string) (*store.Policy, error) { + return nil, nil +} +func (m *mockAuthzService) DeletePolicy(ctx context.Context, accountID, policyID string) error { + return nil +} +func (m *mockAuthzService) ListPolicies(ctx context.Context, accountID string) ([]*store.Policy, error) { + return nil, nil +} +func (m *mockAuthzService) AttachPolicy(ctx context.Context, accountID, policyID string, targetType authz.TargetType, targetID string) (*authz.Attachment, error) { + return nil, nil +} +func (m *mockAuthzService) DetachPolicy(ctx context.Context, accountID, attachmentID string) error { + return nil +} +func (m *mockAuthzService) ListAttachments(ctx context.Context, accountID string, filter authz.AttachmentFilter) ([]*authz.Attachment, error) { + return nil, nil +} + +var _ authz.Service = (*mockAuthzService)(nil) + +func newAccountsHandler(mock *mockAuthzService) *AccountsHandler { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + return NewAccountsHandler(mock, logger) +} + +func accountRequest(body any) *http.Request { + b, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/api/v0/accounts", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + ctx := context.WithValue(req.Context(), middleware.ContextKeyCallerARN, "arn:aws:iam::599476212575:role/privileged-role") + return req.WithContext(ctx) +} + +func TestAccounts_Create_NonPrivilegedRequiresAdminArn(t *testing.T) { + h := newAccountsHandler(&mockAuthzService{}) + + req := accountRequest(EnableAccountRequest{ + AccountID: "754250776154", + Privileged: false, + }) + w := httptest.NewRecorder() + h.Create(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", w.Code) + } + + var resp map[string]any + _ = json.NewDecoder(w.Body).Decode(&resp) + if resp["code"] != "missing-admin-arn" { + t.Errorf("expected code=missing-admin-arn, got %v", resp["code"]) + } +} + +func TestAccounts_Create_PrivilegedDoesNotRequireAdminArn(t *testing.T) { + h := newAccountsHandler(&mockAuthzService{}) + + req := accountRequest(EnableAccountRequest{ + AccountID: "599476212575", + Privileged: true, + }) + w := httptest.NewRecorder() + h.Create(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status 201, got %d", w.Code) + } +} + +func TestAccounts_Create_NonPrivilegedWithAdminArn(t *testing.T) { + var addedAdmin string + mock := &mockAuthzService{ + addAdminFn: func(ctx context.Context, accountID, principalARN, createdBy string) error { + addedAdmin = principalARN + return nil + }, + } + h := newAccountsHandler(mock) + + req := accountRequest(EnableAccountRequest{ + AccountID: "754250776154", + Privileged: false, + AdminArn: "arn:aws:iam::754250776154:role/admin-role", + }) + w := httptest.NewRecorder() + h.Create(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status 201, got %d", w.Code) + } + if addedAdmin != "arn:aws:iam::754250776154:role/admin-role" { + t.Errorf("expected AddAdmin called with admin-role, got %q", addedAdmin) + } +} + +func TestAccounts_Create_PrivilegedIgnoresAdminArn(t *testing.T) { + addAdminCalled := false + mock := &mockAuthzService{ + addAdminFn: func(ctx context.Context, accountID, principalARN, createdBy string) error { + addAdminCalled = true + return nil + }, + } + h := newAccountsHandler(mock) + + req := accountRequest(EnableAccountRequest{ + AccountID: "599476212575", + Privileged: true, + AdminArn: "arn:aws:iam::599476212575:role/some-role", + }) + w := httptest.NewRecorder() + h.Create(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status 201, got %d", w.Code) + } + if addAdminCalled { + t.Error("expected AddAdmin NOT to be called for privileged accounts") + } +} + +func TestAccounts_Create_MissingAccountID(t *testing.T) { + h := newAccountsHandler(&mockAuthzService{}) + + req := accountRequest(EnableAccountRequest{}) + w := httptest.NewRecorder() + h.Create(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d", w.Code) + } + + var resp map[string]any + _ = json.NewDecoder(w.Body).Decode(&resp) + if resp["code"] != "missing-account-id" { + t.Errorf("expected code=missing-account-id, got %v", resp["code"]) + } +} diff --git a/scripts/cedar.sh b/scripts/cedar.sh new file mode 100755 index 00000000..dace483d --- /dev/null +++ b/scripts/cedar.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# create-cluster-readonly-policy.sh +# +# Creates a Cedar policy granting a principal ARN read-only access to clusters +# (ListClusters + DescribeCluster) with NO access to nodepools. +# +# Usage: +# ./create-cluster-readonly-policy.sh [PRINCIPAL_ARN] +# +# Example: +# ./create-cluster-readonly-policy.sh \ +# https://abc123.execute-api.us-east-1.amazonaws.com \ +# 754250776154 \ +# arn:aws:iam::754250776154:role/bff-sigv4-proxy-role + +set -euo pipefail + +API_HOST="${1:?Usage: $0 [PRINCIPAL_ARN]}" +ACCOUNT_ID="${2:?Usage: $0 [PRINCIPAL_ARN]}" +PRINCIPAL_ARN="${3:-arn:aws:iam::${ACCOUNT_ID}:role/bff-sigv4-proxy-role}" +REGION="${AWS_REGION:-us-east-1}" + +CEDAR_POLICY='permit( + ?principal, + action == ROSA::Action::"ListClusters", + resource +); + +permit( + ?principal, + action == ROSA::Action::"DescribeCluster", + resource +);' + +echo "==> Creating Cedar policy for clusters-only read access..." +echo " API Host: ${API_HOST}" +echo " Account ID: ${ACCOUNT_ID}" +echo " Principal ARN: ${PRINCIPAL_ARN}" +echo " Region: ${REGION}" +echo "" + +# Step 1: Create the policy template +echo "--- Step 1: Create policy ---" +POLICY_RESPONSE=$(awscurl --service execute-api \ + --region "${REGION}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-Amz-Account-Id: ${ACCOUNT_ID}" \ + -d "$(jq -n \ + --arg name "clusters-read-only" \ + --arg desc "Read-only access to clusters (ListClusters, DescribeCluster). No nodepool access." \ + --arg policy "${CEDAR_POLICY}" \ + '{name: $name, description: $desc, policy: $policy}')" \ + "${API_HOST}/api/v0/authz/policies") + +echo "${POLICY_RESPONSE}" | jq . + +POLICY_ID=$(echo "${POLICY_RESPONSE}" | jq -r '.policyId') + +if [ -z "${POLICY_ID}" ] || [ "${POLICY_ID}" = "null" ]; then + echo "ERROR: Failed to create policy" >&2 + exit 1 +fi + +echo "" +echo "--- Step 2: Attach policy to principal ---" +ATTACH_RESPONSE=$(awscurl --service execute-api \ + --region "${REGION}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "X-Amz-Account-Id: ${ACCOUNT_ID}" \ + -d "$(jq -n \ + --arg policyId "${POLICY_ID}" \ + --arg targetType "user" \ + --arg targetId "${PRINCIPAL_ARN}" \ + '{policyId: $policyId, targetType: $targetType, targetId: $targetId}')" \ + "${API_HOST}/api/v0/authz/attachments") + +echo "${ATTACH_RESPONSE}" | jq . + +echo "" +echo "==> Done. Policy '${POLICY_ID}' attached to ${PRINCIPAL_ARN}" +echo "" +echo "Allowed actions:" +echo " - ROSA::Action::\"ListClusters\"" +echo " - ROSA::Action::\"DescribeCluster\"" +echo "" +echo "Denied (not granted):" +echo " - All NodePool actions (ListNodePools, DescribeNodePool, CreateNodePool, etc.)" +echo " - All write operations (CreateCluster, UpdateCluster, DeleteCluster)" From 63ecf8f72f004f160049c60619292407012c8bed Mon Sep 17 00:00:00 2001 From: Chris Doan Date: Sun, 9 Aug 2026 11:30:18 -0700 Subject: [PATCH 2/2] add local e2e authz tests with LocalStack, Keycloak OIDC, and Cedar fixes - Fix FIPS + custom endpoint conflict in DynamoDB client - Fix Cedar template resolution for `principal == ?principal` format - Fix ARN normalization for group membership lookups - Add AdminStore.DeleteAll to clean up stale admins on account deletion - Include Cedar policy text in policy API responses - Add LocalStack-based e2e compose with postgres, cedar-agent, and Keycloak - Add Keycloak OIDC realm (rosa-e2e) with supervisor, admin, and user test accounts - Add init scripts for LocalStack IAM/STS and Keycloak OIDC token provisioning - Add full e2e authz test suite covering account lifecycle, Cedar policy CRUD, group/attachment management, authorization evaluation, and ARN normalization Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 + Makefile | 12 +- docs/api/cedar-policies.md | 56 +- hack/keycloak/rosa-e2e-realm.json | 138 +++++ hack/podman-compose.e2e-authz.yaml | 63 ++- platform-api/pkg/authz/authz.go | 15 +- platform-api/pkg/authz/client/dynamodb.go | 22 +- platform-api/pkg/authz/client/mock_avp.go | 20 +- platform-api/pkg/authz/store/admins.go | 17 + platform-api/pkg/handlers/accounts.go | 7 +- platform-api/pkg/handlers/accounts_test.go | 8 +- platform-api/pkg/handlers/authz.go | 4 + platform-api/pkg/handlers/errorcodes.go | 18 +- scripts/cedar.sh | 90 ---- scripts/e2e-init-keycloak.sh | 91 ++++ scripts/e2e-init-localstack.sh | 133 +++++ scripts/run-e2e-authz-local.sh | 121 +++++ scripts/run-e2e-authz.sh | 3 +- test/e2e-authz-local/authz_local_test.go | 598 +++++++++++++++++++++ test/e2e-authz-local/suite_test.go | 13 + 20 files changed, 1288 insertions(+), 143 deletions(-) create mode 100644 hack/keycloak/rosa-e2e-realm.json delete mode 100755 scripts/cedar.sh create mode 100755 scripts/e2e-init-keycloak.sh create mode 100755 scripts/e2e-init-localstack.sh create mode 100755 scripts/run-e2e-authz-local.sh create mode 100644 test/e2e-authz-local/authz_local_test.go create mode 100644 test/e2e-authz-local/suite_test.go diff --git a/.gitignore b/.gitignore index ee75d03c..f29eff14 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,8 @@ vendor/ .env *.local .claude/settings.local.json +rosa-hyperfleet-api +rosa-hyperfleet-api.log # Go workspace go.work diff --git a/Makefile b/Makefile index 82a2991f..89d409d4 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ build-hyperfleet-db build-operator build-api build-api-codegen \ test-hyperfleet-db test-operator test-operator-int test-api test-api-codegen test-clientset \ coverage-api-codegen \ - test-e2e test-e2e-api test-e2e-cli test-e2e-platform-monitoring test-e2e-zoa test-e2e-authz test-e2e-sdk \ + test-e2e test-e2e-api test-e2e-cli test-e2e-platform-monitoring test-e2e-zoa test-e2e-authz test-e2e-authz-local test-e2e-authz-local-down test-e2e-sdk \ e2e-authz-infra-up e2e-authz-infra-down e2e-init-db \ fmt vet verify verify-mod deps mod-tidy \ manifests generate generate-deepcopy generate-clientset verify-clientset setup-envtest \ @@ -99,6 +99,7 @@ help: @echo " test-clientset Clientset unit tests (transport, platform)" @echo " test-integration Integration tests: FleetDB + operator (podman)" @echo " test-e2e-authz E2E authz (starts local infra)" + @echo " test-e2e-authz-local E2E authz with LocalStack (full local flow)" @echo " test-e2e-api E2E API" @echo " test-e2e-cli E2E CLI" @echo " test-e2e-zoa E2E ZOA" @@ -252,6 +253,15 @@ e2e-init-db: test-e2e-authz: e2e-authz-infra-up @./scripts/run-e2e-authz.sh +test-e2e-authz-local: + podman-compose -f hack/podman-compose.e2e-authz.yaml up -d + @sleep 5 + @DYNAMODB_ENDPOINT=http://localhost:4566 ./scripts/run-e2e-authz-local.sh + +test-e2e-authz-local-down: + podman-compose -f hack/podman-compose.e2e-authz.yaml down -v + @rm -f /tmp/e2e-localstack-credentials.env + # ── Code Quality ───────────────────────────────────────────────────────── fmt: diff --git a/docs/api/cedar-policies.md b/docs/api/cedar-policies.md index 8f36f858..9ee314e4 100644 --- a/docs/api/cedar-policies.md +++ b/docs/api/cedar-policies.md @@ -65,25 +65,56 @@ This creates: > **Why** `adminArn` **instead of auto-capturing the caller?** The caller is from the privileged account (599476212575), not the account being created (754250776154). There is no way to infer the correct admin ARN from the cross-account request context. - - ```bash -awscurl --service execute-api --region us-east-1 \ - -X POST \ - -H "Content-Type: application/json" \ - -d '{ - "accountId": "754250776154", - "privileged": false, - "adminArn": "arn:aws:sts::754250776154:assumed-role/OrganizationAccountAccessRole" - }' \ - "${API_URL}/api/v0/accounts" +# as caller supervisor +# delete the customer account, 754250776154 +# redo configuration +awscurl --service execute-api --region us-east-1 \ + -X DELETE \ + "${API_URL}/api/v0/accounts/754250776154" +# as caller supervisor +# add 1st admin for 754250776154 +# note the sts -> iam +awscurl --service execute-api --region us-east-1 \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "accountId": "754250776154", + "privileged": false, + "adminArn": "arn:aws:iam::754250776154:role/OrganizationAccountAccessRole" + }' \ + "${API_URL}/api/v0/accounts" +{"kind":"Account","accountId":"754250776154","policyStoreId":"8DFfo9GpNzftPysCb6k92S","privileged":false,"createdAt":"2026-08-09T00:46:19Z","createdBy":"arn:aws:sts::599476212575:assumed-role/OrganizationAccountAccessRole/rrp-dev-53375"} + + + +# as caller 754250776154, customer, add a new policy +✗ awscurl --service execute-api --region us-east-1 \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "name": "clusters-read-only", + "description": "Read-only access to clusters", + "policy": "permit(principal == ?principal, action in [ROSA::Action::\"ListClusters\", ROSA::Action::\"DescribeCluster\"], resource);" + }' \ + "${API_URL}/api/v0/authz/policies" +{"kind":"Policy","policyId":"KkfzMk9Ti8vXXoTPHDZv2K","name":"clusters-read-only","description":"Read-only access to clusters","createdAt":"2026-08-09T00:47:07Z"} + + +# as caller 154, customer, add a new admin to 154 +awscurl --service execute-api --region us-east-1 \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"principalArn": "arn:aws:iam::754250776154:role/some-new-admin"}' \ + "${API_URL}/api/v0/authz/admins" +{"kind":"Admin","principalArn":"arn:aws:iam::754250776154:role/some-new-admin"} ``` -### Step 2: Create a Cedar policy (admin caller) +### Step 2: Create a CedaÏter policy (admin caller) The bootstrapped admin creates Cedar policy templates. Each template defines what actions are allowed on which resource types. @@ -139,6 +170,7 @@ awscurl --service execute-api --region us-east-1 \ If the initial admin role should be governed solely by Cedar policies rather than having full admin access: ```bash +# as caller 754250776154, delete the admin bff-sigv4-proxy-role awscurl --service execute-api --region us-east-1 \ -X DELETE \ -H "X-Amz-Account-Id: 754250776154" \ diff --git a/hack/keycloak/rosa-e2e-realm.json b/hack/keycloak/rosa-e2e-realm.json new file mode 100644 index 00000000..604562f5 --- /dev/null +++ b/hack/keycloak/rosa-e2e-realm.json @@ -0,0 +1,138 @@ +{ + "realm": "rosa-e2e", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "accessTokenLifespan": 3600, + "ssoSessionMaxLifespan": 86400, + "roles": { + "realm": [ + { + "name": "rosa-supervisor", + "description": "Supervisor role with full access to all accounts" + }, + { + "name": "rosa-admin", + "description": "Account admin role" + }, + { + "name": "rosa-user", + "description": "Regular user role" + } + ] + }, + "clients": [ + { + "clientId": "rosa-api", + "enabled": true, + "publicClient": false, + "secret": "rosa-e2e-secret", + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "standardFlowEnabled": true, + "protocol": "openid-connect", + "redirectUris": ["http://localhost:8000/*"], + "webOrigins": ["http://localhost:8000"], + "attributes": { + "access.token.lifespan": "3600" + }, + "protocolMappers": [ + { + "name": "account-id-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "config": { + "user.attribute": "aws_account_id", + "claim.name": "aws_account_id", + "id.token.claim": "true", + "access.token.claim": "true", + "jsonType.label": "String" + } + }, + { + "name": "caller-arn-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "config": { + "user.attribute": "caller_arn", + "claim.name": "caller_arn", + "id.token.claim": "true", + "access.token.claim": "true", + "jsonType.label": "String" + } + }, + { + "name": "realm-role-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "config": { + "claim.name": "realm_roles", + "id.token.claim": "true", + "access.token.claim": "true", + "multivalued": "true", + "jsonType.label": "String" + } + } + ] + } + ], + "users": [ + { + "username": "supervisor", + "enabled": true, + "email": "supervisor@rosa-e2e.local", + "firstName": "E2E", + "lastName": "Supervisor", + "credentials": [ + { + "type": "password", + "value": "supervisor-password", + "temporary": false + } + ], + "realmRoles": ["rosa-supervisor"], + "attributes": { + "aws_account_id": ["000000000000"], + "caller_arn": ["arn:aws:iam::000000000000:role/SupervisorRole"] + } + }, + { + "username": "customer-admin", + "enabled": true, + "email": "admin@customer.local", + "firstName": "Customer", + "lastName": "Admin", + "credentials": [ + { + "type": "password", + "value": "admin-password", + "temporary": false + } + ], + "realmRoles": ["rosa-admin"], + "attributes": { + "aws_account_id": ["111111111111"], + "caller_arn": ["arn:aws:iam::111111111111:role/CustomerAdminRole"] + } + }, + { + "username": "customer-user", + "enabled": true, + "email": "user@customer.local", + "firstName": "Customer", + "lastName": "User", + "credentials": [ + { + "type": "password", + "value": "user-password", + "temporary": false + } + ], + "realmRoles": ["rosa-user"], + "attributes": { + "aws_account_id": ["111111111111"], + "caller_arn": ["arn:aws:iam::111111111111:role/CustomerUserRole"] + } + } + ] +} diff --git a/hack/podman-compose.e2e-authz.yaml b/hack/podman-compose.e2e-authz.yaml index efea3c36..56e61848 100644 --- a/hack/podman-compose.e2e-authz.yaml +++ b/hack/podman-compose.e2e-authz.yaml @@ -1,18 +1,67 @@ version: '3.8' -# E2E test infrastructure for authz testing -# Usage: podman-compose -f docker-compose.e2e.yml up -d +# E2E test infrastructure for local authz testing with LocalStack +# Usage: podman-compose -f hack/podman-compose.e2e-authz.yaml up -d +# +# Requires: LOCALSTACK_AUTH_TOKEN env var for LocalStack Pro features +# DynamoDB is available in the free Community edition too. services: - dynamodb-local: - image: docker.io/amazon/dynamodb-local:latest - container_name: rosa-dynamodb-local + postgres: + image: docker.io/library/postgres:16-alpine + container_name: rosa-postgres ports: - - "8180:8000" - command: ["-jar", "DynamoDBLocal.jar", "-sharedDb", "-inMemory"] + - "5432:5432" + environment: + POSTGRES_USER: rosa + POSTGRES_PASSWORD: rosa + POSTGRES_DB: hyperfleet + healthcheck: + test: ["CMD-SHELL", "pg_isready -U rosa -d hyperfleet"] + interval: 2s + timeout: 5s + retries: 5 + + localstack: + image: localstack/localstack-pro:latest + container_name: rosa-localstack + ports: + - "4566:4566" + environment: + - LOCALSTACK_AUTH_TOKEN=${LOCALSTACK_AUTH_TOKEN:-} + - SERVICES=dynamodb,iam,sts,verifiedpermissions + - SKIP_SIGNATURE_VALIDATION=1 + - DEBUG=${LOCALSTACK_DEBUG:-0} + - EAGER_SERVICE_LOADING=1 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:4566/_localstack/health"] + interval: 5s + timeout: 10s + retries: 10 cedar-agent: image: docker.io/permitio/cedar-agent:latest container_name: rosa-cedar-agent ports: - "8181:8180" + + keycloak: + image: quay.io/keycloak/keycloak:26.0 + container_name: rosa-keycloak + command: start-dev --import-realm + ports: + - "8080:8080" + - "9000:9000" + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HTTP_RELATIVE_PATH: / + KC_HEALTH_ENABLED: "true" + volumes: + - ./keycloak/rosa-e2e-realm.json:/opt/keycloak/data/import/rosa-e2e-realm.json:ro + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && grep -q '200\\|UP' <&3"] + interval: 5s + timeout: 10s + retries: 15 + start_period: 30s diff --git a/platform-api/pkg/authz/authz.go b/platform-api/pkg/authz/authz.go index 64bf9168..408357a5 100644 --- a/platform-api/pkg/authz/authz.go +++ b/platform-api/pkg/authz/authz.go @@ -162,8 +162,10 @@ func (a *authorizerImpl) Authorize(ctx context.Context, req *AuthzRequest) (bool return true, nil } - // Get user's group memberships - groups, err := a.memberStore.GetUserGroups(ctx, req.AccountID, req.CallerARN) + // Get user's group memberships — normalize ARN so STS assumed-role ARNs + // match the IAM role ARN stored when the member was added. + normalizedARN := store.NormalizeAssumedRoleARN(req.CallerARN) + groups, err := a.memberStore.GetUserGroups(ctx, req.AccountID, normalizedARN) if err != nil { return false, fmt.Errorf("failed to get user groups: %w", err) } @@ -195,6 +197,9 @@ func (a *authorizerImpl) buildAVPRequest(req *AuthzRequest, groups []string, pol // Build principal — normalize STS assumed-role ARNs to IAM role ARNs // so they match the IAM ARN used when attaching policies. principalARN := store.NormalizeAssumedRoleARN(req.CallerARN) + if principalARN != req.CallerARN { + a.logger.Info("normalized assumed-role ARN for Cedar evaluation", "original", req.CallerARN, "normalized", principalARN) + } principal := &avptypes.EntityIdentifier{ EntityType: aws.String("ROSA::Principal"), EntityId: aws.String(principalARN), @@ -395,6 +400,12 @@ func (a *authorizerImpl) DisableAccount(ctx context.Context, accountID string) e } } + // Clean up all admins for this account so stale entries don't carry over + // if the account is re-provisioned with a different adminArn. + if err := a.adminStore.DeleteAll(ctx, accountID); err != nil { + a.logger.Warn("failed to clean up admins during account deletion", "error", err, "account_id", accountID) + } + return a.accountStore.Delete(ctx, accountID) } diff --git a/platform-api/pkg/authz/client/dynamodb.go b/platform-api/pkg/authz/client/dynamodb.go index 7d3e3a8f..aeafea75 100644 --- a/platform-api/pkg/authz/client/dynamodb.go +++ b/platform-api/pkg/authz/client/dynamodb.go @@ -12,22 +12,26 @@ import ( // NewDynamoDBClient creates a new DynamoDB client using the default AWS config // If endpoint is provided, it overrides the default AWS endpoint (for local development) func NewDynamoDBClient(ctx context.Context, region, endpoint string) (DynamoDBClient, error) { - // FedRAMP SC-13 / IA-7: enable FIPS 140-3 validated endpoints. - // config.WithUseFIPSEndpoint routes all DynamoDB API calls through - // FIPS endpoints (e.g. dynamodb-fips.us-east-1.amazonaws.com) when - // operating in a FedRAMP-authorized environment. - cfg, err := config.LoadDefaultConfig(ctx, + loadOpts := []func(*config.LoadOptions) error{ config.WithRegion(region), - config.WithUseFIPSEndpoint(aws.FIPSEndpointStateEnabled), - ) + } + if endpoint == "" { + // FedRAMP SC-13 / IA-7: enable FIPS 140-3 validated endpoints. + // config.WithUseFIPSEndpoint routes all DynamoDB API calls through + // FIPS endpoints (e.g. dynamodb-fips.us-east-1.amazonaws.com) when + // operating in a FedRAMP-authorized environment. + // Disabled when using a custom endpoint (local/LocalStack) since the + // SDK rejects FIPS + custom endpoint as an invalid combination. + loadOpts = append(loadOpts, config.WithUseFIPSEndpoint(aws.FIPSEndpointStateEnabled)) + } + + cfg, err := config.LoadDefaultConfig(ctx, loadOpts...) if err != nil { return nil, err } var opts []func(*dynamodb.Options) if endpoint != "" { - // For local DynamoDB, use dummy credentials and custom endpoint. - // The local endpoint override disables FIPS routing for development only. opts = append(opts, func(o *dynamodb.Options) { o.BaseEndpoint = aws.String(endpoint) o.Credentials = credentials.NewStaticCredentialsProvider("dummy", "dummy", "") diff --git a/platform-api/pkg/authz/client/mock_avp.go b/platform-api/pkg/authz/client/mock_avp.go index fc04bf81..c3a742c5 100644 --- a/platform-api/pkg/authz/client/mock_avp.go +++ b/platform-api/pkg/authz/client/mock_avp.go @@ -140,15 +140,23 @@ func (m *MockAVPClient) syncPolicies(ctx context.Context, policyStoreID string) return nil } -// resolvePrincipal replaces ?principal in Cedar template text with the concrete principal entity. -// It uses "principal in" so that Cedar traverses the entity hierarchy — this allows -// group-based policies to match any principal that is a member (descendant) of the group. -// For direct user attachments, "in" still works because `A in A` is always true in Cedar. +// resolvePrincipal replaces the ?principal template slot in Cedar policy text +// with a concrete principal entity. Uses "principal in" so Cedar traverses the +// entity hierarchy — group members match via ancestry, and direct matches still +// work because `A in A` is always true in Cedar. +// +// Handles both AVP template forms: +// - "principal == ?principal" → "principal in Entity::"id"" +// - bare "?principal" → "principal in Entity::"id"" func resolvePrincipal(cedarTemplate string, principal *avptypes.EntityIdentifier) string { entityType := aws.ToString(principal.EntityType) entityID := aws.ToString(principal.EntityId) - principalEntity := fmt.Sprintf(`principal in %s::"%s"`, entityType, entityID) - return strings.ReplaceAll(cedarTemplate, "?principal", principalEntity) + principalConstraint := fmt.Sprintf(`principal in %s::"%s"`, entityType, entityID) + + if strings.Contains(cedarTemplate, "principal == ?principal") { + return strings.ReplaceAll(cedarTemplate, "principal == ?principal", principalConstraint) + } + return strings.ReplaceAll(cedarTemplate, "?principal", principalConstraint) } // CreatePolicyStore returns a dummy policy store ID and initializes tracking. diff --git a/platform-api/pkg/authz/store/admins.go b/platform-api/pkg/authz/store/admins.go index dbf62806..9f80af9a 100644 --- a/platform-api/pkg/authz/store/admins.go +++ b/platform-api/pkg/authz/store/admins.go @@ -90,6 +90,7 @@ func (s *AdminStore) Remove(ctx context.Context, accountID, principalARN string) func (s *AdminStore) IsAdmin(ctx context.Context, accountID, principalARN string) (bool, error) { arnsToCheck := []string{principalARN} if normalized := NormalizeAssumedRoleARN(principalARN); normalized != principalARN { + s.logger.Info("normalized assumed-role ARN for admin check", "original", principalARN, "normalized", normalized) arnsToCheck = append(arnsToCheck, normalized) } @@ -159,6 +160,22 @@ func (s *AdminStore) List(ctx context.Context, accountID string) ([]*Admin, erro return admins, nil } +// DeleteAll removes all admins for an account. Used during account deletion +// to prevent stale admin entries from carrying over if the account is re-provisioned. +func (s *AdminStore) DeleteAll(ctx context.Context, accountID string) error { + admins, err := s.List(ctx, accountID) + if err != nil { + return fmt.Errorf("failed to list admins for deletion: %w", err) + } + for _, admin := range admins { + if err := s.Remove(ctx, accountID, admin.PrincipalARN); err != nil { + return fmt.Errorf("failed to remove admin %s: %w", admin.PrincipalARN, err) + } + } + s.logger.Info("all admins removed for account", "account_id", accountID, "count", len(admins)) + return nil +} + // ListARNs returns the ARNs of all admins for an account func (s *AdminStore) ListARNs(ctx context.Context, accountID string) ([]string, error) { admins, err := s.List(ctx, accountID) diff --git a/platform-api/pkg/handlers/accounts.go b/platform-api/pkg/handlers/accounts.go index 8f416eba..a7f5d7e6 100644 --- a/platform-api/pkg/handlers/accounts.go +++ b/platform-api/pkg/handlers/accounts.go @@ -69,7 +69,7 @@ func (h *AccountsHandler) Create(w http.ResponseWriter, r *http.Request) { } if !req.Privileged && req.AdminArn == "" { - h.writeError(w, http.StatusBadRequest, "missing-admin-arn", "adminArn is required for non-privileged accounts") + writeAPIError(w, ErrAccountCreateMissingAdminArn, h.logger) return } @@ -104,14 +104,15 @@ func (h *AccountsHandler) Create(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(AccountResponse{ + err = json.NewEncoder(w).Encode(AccountResponse{ Kind: "Account", AccountID: account.AccountID, PolicyStoreID: account.PolicyStoreID, Privileged: account.Privileged, CreatedAt: account.CreatedAt, CreatedBy: account.CreatedBy, - }); err != nil { + }) + if err != nil { h.logger.Error("failed to write response", "error", err) } } diff --git a/platform-api/pkg/handlers/accounts_test.go b/platform-api/pkg/handlers/accounts_test.go index 071b485d..9f67b2f7 100644 --- a/platform-api/pkg/handlers/accounts_test.go +++ b/platform-api/pkg/handlers/accounts_test.go @@ -132,8 +132,8 @@ func TestAccounts_Create_NonPrivilegedRequiresAdminArn(t *testing.T) { var resp map[string]any _ = json.NewDecoder(w.Body).Decode(&resp) - if resp["code"] != "missing-admin-arn" { - t.Errorf("expected code=missing-admin-arn, got %v", resp["code"]) + if resp["code"] != "ACCOUNTS-MGMT-CREATE-003" { + t.Errorf("expected code=ACCOUNTS-MGMT-CREATE-003 (missing-admin-arn), got %v", resp["code"]) } } @@ -217,7 +217,7 @@ func TestAccounts_Create_MissingAccountID(t *testing.T) { var resp map[string]any _ = json.NewDecoder(w.Body).Decode(&resp) - if resp["code"] != "missing-account-id" { - t.Errorf("expected code=missing-account-id, got %v", resp["code"]) + if resp["code"] != "ACCOUNTS-MGMT-CREATE-002" { + t.Errorf("expected code=ACCOUNTS-MGMT-CREATE-002 (missing-account-id), got %v", resp["code"]) } } diff --git a/platform-api/pkg/handlers/authz.go b/platform-api/pkg/handlers/authz.go index 6516d07f..a0dcfdc3 100644 --- a/platform-api/pkg/handlers/authz.go +++ b/platform-api/pkg/handlers/authz.go @@ -41,6 +41,7 @@ type PolicyResponse struct { PolicyID string `json:"policyId"` Name string `json:"name"` Description string `json:"description,omitempty"` + CedarPolicy string `json:"policy,omitempty"` CreatedAt string `json:"createdAt"` } @@ -167,6 +168,7 @@ func (h *AuthzHandler) CreatePolicy(w http.ResponseWriter, r *http.Request) { PolicyID: p.PolicyID, Name: p.Name, Description: p.Description, + CedarPolicy: p.CedarPolicy, CreatedAt: p.CreatedAt, }); err != nil { h.logger.Error("failed to write response", "error", err) @@ -228,6 +230,7 @@ func (h *AuthzHandler) GetPolicy(w http.ResponseWriter, r *http.Request) { PolicyID: p.PolicyID, Name: p.Name, Description: p.Description, + CedarPolicy: p.CedarPolicy, CreatedAt: p.CreatedAt, }); err != nil { h.logger.Error("failed to write response", "error", err) @@ -258,6 +261,7 @@ func (h *AuthzHandler) UpdatePolicy(w http.ResponseWriter, r *http.Request) { PolicyID: p.PolicyID, Name: p.Name, Description: p.Description, + CedarPolicy: p.CedarPolicy, CreatedAt: p.CreatedAt, }); err != nil { h.logger.Error("failed to write response", "error", err) diff --git a/platform-api/pkg/handlers/errorcodes.go b/platform-api/pkg/handlers/errorcodes.go index 05dc8647..485d6102 100644 --- a/platform-api/pkg/handlers/errorcodes.go +++ b/platform-api/pkg/handlers/errorcodes.go @@ -81,11 +81,12 @@ var ( // Accounts error codes var ( - ErrAccountCreateInvalidBody APIError - ErrAccountCreateMissingID APIError - ErrAccountCreateCheckFailed APIError - ErrAccountCreateExists APIError - ErrAccountCreateFailed APIError + ErrAccountCreateInvalidBody APIError + ErrAccountCreateMissingID APIError + ErrAccountCreateMissingAdminArn APIError + ErrAccountCreateCheckFailed APIError + ErrAccountCreateExists APIError + ErrAccountCreateFailed APIError ErrAccountListFailed APIError @@ -281,9 +282,10 @@ func init() { // Accounts — Create ErrAccountCreateInvalidBody = APIError{Code: "ACCOUNTS-MGMT-CREATE-001", HTTPStatus: http.StatusBadRequest, Message: "Invalid request body"} ErrAccountCreateMissingID = APIError{Code: "ACCOUNTS-MGMT-CREATE-002", HTTPStatus: http.StatusBadRequest, Message: "accountId is required"} - ErrAccountCreateCheckFailed = APIError{Code: "ACCOUNTS-MGMT-CREATE-003", HTTPStatus: http.StatusInternalServerError, Message: "Failed to check account status"} - ErrAccountCreateExists = APIError{Code: "ACCOUNTS-MGMT-CREATE-004", HTTPStatus: http.StatusConflict, Message: "Account is already enabled"} - ErrAccountCreateFailed = APIError{Code: "ACCOUNTS-MGMT-CREATE-005", HTTPStatus: http.StatusInternalServerError, Message: "Failed to enable account"} + ErrAccountCreateMissingAdminArn = APIError{Code: "ACCOUNTS-MGMT-CREATE-003", HTTPStatus: http.StatusBadRequest, Message: "adminArn is required for non-privileged accounts"} + ErrAccountCreateCheckFailed = APIError{Code: "ACCOUNTS-MGMT-CREATE-004", HTTPStatus: http.StatusInternalServerError, Message: "Failed to check account status"} + ErrAccountCreateExists = APIError{Code: "ACCOUNTS-MGMT-CREATE-005", HTTPStatus: http.StatusConflict, Message: "Account is already enabled"} + ErrAccountCreateFailed = APIError{Code: "ACCOUNTS-MGMT-CREATE-006", HTTPStatus: http.StatusInternalServerError, Message: "Failed to enable account"} // Accounts — List ErrAccountListFailed = APIError{Code: "ACCOUNTS-MGMT-LIST-001", HTTPStatus: http.StatusInternalServerError, Message: "Failed to list accounts"} diff --git a/scripts/cedar.sh b/scripts/cedar.sh deleted file mode 100755 index dace483d..00000000 --- a/scripts/cedar.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env bash -# create-cluster-readonly-policy.sh -# -# Creates a Cedar policy granting a principal ARN read-only access to clusters -# (ListClusters + DescribeCluster) with NO access to nodepools. -# -# Usage: -# ./create-cluster-readonly-policy.sh [PRINCIPAL_ARN] -# -# Example: -# ./create-cluster-readonly-policy.sh \ -# https://abc123.execute-api.us-east-1.amazonaws.com \ -# 754250776154 \ -# arn:aws:iam::754250776154:role/bff-sigv4-proxy-role - -set -euo pipefail - -API_HOST="${1:?Usage: $0 [PRINCIPAL_ARN]}" -ACCOUNT_ID="${2:?Usage: $0 [PRINCIPAL_ARN]}" -PRINCIPAL_ARN="${3:-arn:aws:iam::${ACCOUNT_ID}:role/bff-sigv4-proxy-role}" -REGION="${AWS_REGION:-us-east-1}" - -CEDAR_POLICY='permit( - ?principal, - action == ROSA::Action::"ListClusters", - resource -); - -permit( - ?principal, - action == ROSA::Action::"DescribeCluster", - resource -);' - -echo "==> Creating Cedar policy for clusters-only read access..." -echo " API Host: ${API_HOST}" -echo " Account ID: ${ACCOUNT_ID}" -echo " Principal ARN: ${PRINCIPAL_ARN}" -echo " Region: ${REGION}" -echo "" - -# Step 1: Create the policy template -echo "--- Step 1: Create policy ---" -POLICY_RESPONSE=$(awscurl --service execute-api \ - --region "${REGION}" \ - -X POST \ - -H "Content-Type: application/json" \ - -H "X-Amz-Account-Id: ${ACCOUNT_ID}" \ - -d "$(jq -n \ - --arg name "clusters-read-only" \ - --arg desc "Read-only access to clusters (ListClusters, DescribeCluster). No nodepool access." \ - --arg policy "${CEDAR_POLICY}" \ - '{name: $name, description: $desc, policy: $policy}')" \ - "${API_HOST}/api/v0/authz/policies") - -echo "${POLICY_RESPONSE}" | jq . - -POLICY_ID=$(echo "${POLICY_RESPONSE}" | jq -r '.policyId') - -if [ -z "${POLICY_ID}" ] || [ "${POLICY_ID}" = "null" ]; then - echo "ERROR: Failed to create policy" >&2 - exit 1 -fi - -echo "" -echo "--- Step 2: Attach policy to principal ---" -ATTACH_RESPONSE=$(awscurl --service execute-api \ - --region "${REGION}" \ - -X POST \ - -H "Content-Type: application/json" \ - -H "X-Amz-Account-Id: ${ACCOUNT_ID}" \ - -d "$(jq -n \ - --arg policyId "${POLICY_ID}" \ - --arg targetType "user" \ - --arg targetId "${PRINCIPAL_ARN}" \ - '{policyId: $policyId, targetType: $targetType, targetId: $targetId}')" \ - "${API_HOST}/api/v0/authz/attachments") - -echo "${ATTACH_RESPONSE}" | jq . - -echo "" -echo "==> Done. Policy '${POLICY_ID}' attached to ${PRINCIPAL_ARN}" -echo "" -echo "Allowed actions:" -echo " - ROSA::Action::\"ListClusters\"" -echo " - ROSA::Action::\"DescribeCluster\"" -echo "" -echo "Denied (not granted):" -echo " - All NodePool actions (ListNodePools, DescribeNodePool, CreateNodePool, etc.)" -echo " - All write operations (CreateCluster, UpdateCluster, DeleteCluster)" diff --git a/scripts/e2e-init-keycloak.sh b/scripts/e2e-init-keycloak.sh new file mode 100755 index 00000000..2e128915 --- /dev/null +++ b/scripts/e2e-init-keycloak.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Initialize and verify Keycloak OIDC provider for local e2e authz testing. +# The realm, client, and users are imported from the realm JSON at container start. +# This script waits for Keycloak to be ready, then fetches OIDC tokens +# for each test user and writes them to $CREDENTIALS_FILE. +# +# Exports (via $CREDENTIALS_FILE): +# KEYCLOAK_ISSUER_URL — OIDC issuer URL for the rosa-e2e realm +# SUPERVISOR_OIDC_TOKEN — Access token for the supervisor user +# CUSTOMER_ADMIN_OIDC_TOKEN — Access token for the customer-admin user +# CUSTOMER_USER_OIDC_TOKEN — Access token for the customer-user user + +KEYCLOAK_URL="${KEYCLOAK_URL:-http://localhost:8080}" +KEYCLOAK_MGMT_URL="${KEYCLOAK_MGMT_URL:-http://localhost:9000}" +REALM="rosa-e2e" +CLIENT_ID="rosa-api" +CLIENT_SECRET="rosa-e2e-secret" + +CREDENTIALS_FILE="${KEYCLOAK_CREDENTIALS_FILE:-/tmp/e2e-keycloak-credentials.env}" + +echo "=== Initializing Keycloak OIDC Provider ===" +echo "Keycloak URL: $KEYCLOAK_URL" +echo "Realm: $REALM" +echo "" + +# Wait for Keycloak to be healthy (health endpoint is on management port 9000) +echo "Waiting for Keycloak..." +for i in {1..60}; do + if curl -sf "$KEYCLOAK_MGMT_URL/health/ready" >/dev/null 2>&1; then + echo "Keycloak is ready!" + break + fi + if [ "$i" -eq 60 ]; then + echo "Timeout waiting for Keycloak after 60s" + exit 1 + fi + sleep 1 +done + +ISSUER_URL="$KEYCLOAK_URL/realms/$REALM" +TOKEN_URL="$ISSUER_URL/protocol/openid-connect/token" + +# Verify OIDC discovery endpoint +echo "" +echo "Verifying OIDC discovery..." +DISCOVERY=$(curl -sf "$ISSUER_URL/.well-known/openid-configuration") +echo " issuer: $(echo "$DISCOVERY" | python3 -c "import sys,json; print(json.load(sys.stdin)['issuer'])")" +echo " token_endpoint: $(echo "$DISCOVERY" | python3 -c "import sys,json; print(json.load(sys.stdin)['token_endpoint'])")" +echo " jwks_uri: $(echo "$DISCOVERY" | python3 -c "import sys,json; print(json.load(sys.stdin)['jwks_uri'])")" + +# Fetch OIDC token for a user via Resource Owner Password Credentials grant +fetch_token() { + local username="$1" + local password="$2" + local label="$3" + + echo " Fetching token for $label ($username)..." + TOKEN_RESPONSE=$(curl -sf -X POST "$TOKEN_URL" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password" \ + -d "client_id=$CLIENT_ID" \ + -d "client_secret=$CLIENT_SECRET" \ + -d "username=$username" \ + -d "password=$password" \ + -d "scope=openid") + + ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") + echo " token length: ${#ACCESS_TOKEN} chars" + echo "$ACCESS_TOKEN" +} + +echo "" +echo "Fetching OIDC tokens..." + +SUPERVISOR_TOKEN=$(fetch_token "supervisor" "supervisor-password" "supervisor") +ADMIN_TOKEN=$(fetch_token "customer-admin" "admin-password" "customer-admin") +USER_TOKEN=$(fetch_token "customer-user" "user-password" "customer-user") + +# Write credentials to file +cat > "$CREDENTIALS_FILE" </dev/null 2>&1; then + echo "LocalStack is ready!" + break + fi + if [ "$i" -eq 30 ]; then + echo "Timeout waiting for LocalStack" + exit 1 + fi + sleep 1 +done + +# Verify STS is available +echo "" +echo "Verifying STS..." +aws sts get-caller-identity --endpoint-url "$ENDPOINT" --region "$REGION" 2>&1 || true + +# Create IAM role for supervisor +echo "" +echo "Creating supervisor IAM role..." +TRUST_POLICY='{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": "sts:AssumeRole" + }] +}' + +aws iam create-role \ + --endpoint-url "$ENDPOINT" \ + --region "$REGION" \ + --role-name "SupervisorRole" \ + --assume-role-policy-document "$TRUST_POLICY" \ + --path "/" 2>/dev/null || echo " (role may already exist)" + +aws iam attach-role-policy \ + --endpoint-url "$ENDPOINT" \ + --region "$REGION" \ + --role-name "SupervisorRole" \ + --policy-arn "arn:aws:iam::aws:policy/AdministratorAccess" 2>/dev/null || true + +# Create IAM role for customer account +echo "Creating customer IAM role..." +aws iam create-role \ + --endpoint-url "$ENDPOINT" \ + --region "$REGION" \ + --role-name "CustomerAdminRole" \ + --assume-role-policy-document "$TRUST_POLICY" \ + --path "/" 2>/dev/null || echo " (role may already exist)" + +# Assume supervisor role to get STS credentials +echo "" +echo "Assuming supervisor role via STS..." +STS_OUTPUT=$(aws sts assume-role \ + --endpoint-url "$ENDPOINT" \ + --region "$REGION" \ + --role-arn "arn:aws:iam::${SUPERVISOR_ACCOUNT}:role/SupervisorRole" \ + --role-session-name "e2e-supervisor" \ + --output json) + +SUPERVISOR_ACCESS_KEY_ID=$(echo "$STS_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['Credentials']['AccessKeyId'])") +SUPERVISOR_SECRET_ACCESS_KEY=$(echo "$STS_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['Credentials']['SecretAccessKey'])") +SUPERVISOR_SESSION_TOKEN=$(echo "$STS_OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['Credentials']['SessionToken'])") + +echo "Supervisor STS credentials obtained." + +# Verify assumed role identity +echo "" +echo "Verifying supervisor identity..." +AWS_ACCESS_KEY_ID="$SUPERVISOR_ACCESS_KEY_ID" \ +AWS_SECRET_ACCESS_KEY="$SUPERVISOR_SECRET_ACCESS_KEY" \ +AWS_SESSION_TOKEN="$SUPERVISOR_SESSION_TOKEN" \ + aws sts get-caller-identity --endpoint-url "$ENDPOINT" --region "$REGION" + +# Seed supervisor as privileged account in DynamoDB accounts table +echo "" +echo "Seeding supervisor as privileged account in DynamoDB..." +DYNAMODB_ENDPOINT="${DYNAMODB_ENDPOINT:-$ENDPOINT}" +if aws dynamodb get-item --endpoint-url "$DYNAMODB_ENDPOINT" --region "$REGION" \ + --table-name "rosa-authz-accounts" \ + --key '{"accountId": {"S": "'"$SUPERVISOR_ACCOUNT"'"}}' \ + --projection-expression "accountId" 2>/dev/null | grep -q "$SUPERVISOR_ACCOUNT"; then + echo " Privileged account $SUPERVISOR_ACCOUNT already exists, skipping..." +else + aws dynamodb put-item --endpoint-url "$DYNAMODB_ENDPOINT" --region "$REGION" \ + --table-name "rosa-authz-accounts" \ + --item '{ + "accountId": {"S": "'"$SUPERVISOR_ACCOUNT"'"}, + "privileged": {"BOOL": true}, + "createdAt": {"S": "'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}, + "createdBy": {"S": "e2e-init-localstack"} + }' + echo " Privileged account $SUPERVISOR_ACCOUNT created." +fi + +# Write credentials to file for the run script to source +cat > "$CREDENTIALS_FILE" </dev/null; then + kill "$(cat "$PIDFILE")" 2>/dev/null || true + wait "$(cat "$PIDFILE")" 2>/dev/null || true + fi + rm -f "$PIDFILE" "$CREDENTIALS_FILE" "$KEYCLOAK_CREDENTIALS_FILE" +} +trap cleanup EXIT INT TERM + +echo "=== Local Authz E2E Tests ===" +echo "LocalStack: $LOCALSTACK_ENDPOINT" +echo "DynamoDB: $DYNAMODB_ENDPOINT" +echo "Cedar Agent: $CEDAR_AGENT_ENDPOINT" +echo "Keycloak: $KEYCLOAK_URL" +echo "Postgres: $POSTGRES_DSN" +echo "" + +# 1. Initialize DynamoDB tables (must exist before seeding accounts) +echo "Initializing DynamoDB tables..." +DYNAMODB_ENDPOINT="$DYNAMODB_ENDPOINT" bash scripts/e2e-init-dynamodb.sh + +# 2. Initialize LocalStack IAM/STS — get supervisor credentials, seed privileged account +echo "" +echo "Initializing LocalStack IAM/STS..." +AWS_ENDPOINT_URL="$LOCALSTACK_ENDPOINT" \ +DYNAMODB_ENDPOINT="$DYNAMODB_ENDPOINT" \ +CREDENTIALS_FILE="$CREDENTIALS_FILE" \ + bash scripts/e2e-init-localstack.sh + +# Source the STS credentials +# shellcheck source=/dev/null +source "$CREDENTIALS_FILE" + +# 3. Initialize Keycloak OIDC provider — fetch tokens for test users +echo "" +echo "Initializing Keycloak OIDC provider..." +KEYCLOAK_URL="$KEYCLOAK_URL" \ +KEYCLOAK_CREDENTIALS_FILE="$KEYCLOAK_CREDENTIALS_FILE" \ + bash scripts/e2e-init-keycloak.sh + +# shellcheck source=/dev/null +source "$KEYCLOAK_CREDENTIALS_FILE" + +# 4. Build +echo "" +echo "Building service..." +(cd platform-api && go build -o "../$BINARY" ./cmd) + +# 5. Start service with STS credentials from LocalStack +echo "Starting service..." +DYNAMODB_ENDPOINT="$DYNAMODB_ENDPOINT" \ +CEDAR_AGENT_ENDPOINT="$CEDAR_AGENT_ENDPOINT" \ +AUTHZ_ENABLED=true \ +AWS_REGION="${AWS_REGION:-us-east-1}" \ +AWS_ACCESS_KEY_ID="$SUPERVISOR_ACCESS_KEY_ID" \ +AWS_SECRET_ACCESS_KEY="$SUPERVISOR_SECRET_ACCESS_KEY" \ +AWS_SESSION_TOKEN="$SUPERVISOR_SESSION_TOKEN" \ + "$BINARY" serve \ + --postgres-dsn="$POSTGRES_DSN" \ + --health-port=8081 \ + --log-level=debug \ + --log-format=text > "$LOGFILE" 2>&1 & +echo $! > "$PIDFILE" + +echo "Waiting for service to be ready..." +for i in $(seq 1 $MAX_WAIT); do + if curl -sf "$READY_URL" > /dev/null 2>&1; then + echo "Service ready after ${i}s" + break + fi + if ! kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + echo "Service exited unexpectedly. Log output:" + cat "$LOGFILE" + exit 1 + fi + sleep 1 +done + +if ! curl -sf "$READY_URL" > /dev/null 2>&1; then + echo "Service failed to become ready after ${MAX_WAIT}s. Log output:" + tail -50 "$LOGFILE" + exit 1 +fi + +# 6. Run tests with STS credentials and OIDC tokens +echo "" +echo "Running local authz E2E tests..." +E2E_BASE_URL="$BASE_URL" \ +DYNAMODB_ENDPOINT="$DYNAMODB_ENDPOINT" \ +KEYCLOAK_ISSUER_URL="$KEYCLOAK_ISSUER_URL" \ +SUPERVISOR_OIDC_TOKEN="$SUPERVISOR_OIDC_TOKEN" \ +CUSTOMER_ADMIN_OIDC_TOKEN="$CUSTOMER_ADMIN_OIDC_TOKEN" \ +CUSTOMER_USER_OIDC_TOKEN="$CUSTOMER_USER_OIDC_TOKEN" \ +AWS_REGION="${AWS_REGION:-us-east-1}" \ +AWS_ACCESS_KEY_ID="$SUPERVISOR_ACCESS_KEY_ID" \ +AWS_SECRET_ACCESS_KEY="$SUPERVISOR_SECRET_ACCESS_KEY" \ +AWS_SESSION_TOKEN="$SUPERVISOR_SESSION_TOKEN" \ + ginkgo -v ./test/e2e-authz-local diff --git a/scripts/run-e2e-authz.sh b/scripts/run-e2e-authz.sh index aa543ee8..68979779 100755 --- a/scripts/run-e2e-authz.sh +++ b/scripts/run-e2e-authz.sh @@ -26,6 +26,7 @@ DYNAMODB_ENDPOINT=http://localhost:8180 \ CEDAR_AGENT_ENDPOINT=http://localhost:8181 \ AUTHZ_ENABLED=true \ "$BINARY" serve \ + --postgres-dsn="postgres://rosa:rosa@localhost:5432/hyperfleet?sslmode=disable" \ --log-level=debug \ --log-format=text > "$LOGFILE" 2>&1 & echo $! > "$PIDFILE" @@ -51,4 +52,4 @@ if ! curl -sf "$READY_URL" > /dev/null 2>&1; then fi echo "Running authz E2E tests..." -E2E_BASE_URL="$BASE_URL" ginkgo -v --focus="Authz" ./test/e2e +E2E_BASE_URL="$BASE_URL" ginkgo -v --focus="Authz" ./test/e2e-api diff --git a/test/e2e-authz-local/authz_local_test.go b/test/e2e-authz-local/authz_local_test.go new file mode 100644 index 00000000..7e61df9a --- /dev/null +++ b/test/e2e-authz-local/authz_local_test.go @@ -0,0 +1,598 @@ +package authzlocal_test + +import ( + "fmt" + "net/http" + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + awstest "github.com/openshift-online/rosa-hyperfleet-api/test/helpers/aws" +) + +const ( + privilegedAccountID = "000000000000" + supervisorARN = "arn:aws:iam::000000000000:user/supervisor" + nonPrivilegedAccountID = "111111111111" + adminARN = "arn:aws:iam::111111111111:role/TestAdminRole" + nonAdminARN = "arn:aws:iam::111111111111:role/AppRole" + defaultTimeout = 30 * time.Second +) + +var _ = Describe("Local Authz E2E", Ordered, func() { + var client *awstest.APIClient + + BeforeAll(func() { + baseURL := os.Getenv("E2E_BASE_URL") + if baseURL == "" { + baseURL = "http://localhost:8000" + } + client = awstest.NewAPIClient(baseURL) + + Eventually(func() error { + return client.CheckReady() + }, defaultTimeout, 1*time.Second).Should(Succeed(), "Service should be ready") + }) + + Context("Supervisor (Privileged Account) Access", Ordered, func() { + BeforeAll(func() { + client.CallerARN = supervisorARN + }) + + It("should access the health endpoints", func() { + resp, err := client.Get("/api/v0/live", privilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + resp, err = client.Get("/api/v0/ready", privilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("should list accounts", func() { + resp, err := client.Get("/api/v0/accounts", privilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("should get the privileged account", func() { + resp, err := client.Get( + fmt.Sprintf("/api/v0/accounts/%s", privilegedAccountID), + privilegedAccountID, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + data, err := resp.JSON() + Expect(err).NotTo(HaveOccurred()) + Expect(data["accountId"]).To(Equal(privilegedAccountID)) + Expect(data["privileged"]).To(BeTrue()) + }) + + It("should access authz admin endpoints", func() { + resp, err := client.Get("/api/v0/authz/admins", privilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("should not have policy store (privileged accounts bypass Cedar)", func() { + resp, err := client.Get("/api/v0/authz/policies", privilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusInternalServerError)) + Expect(string(resp.Body)).To(ContainSubstring("internal-error")) + }) + }) + + Context("Account Provisioning", Ordered, func() { + It("should provision a non-privileged account with adminArn", func() { + body := map[string]interface{}{ + "accountId": nonPrivilegedAccountID, + "privileged": false, + "adminArn": adminARN, + } + + client.CallerARN = supervisorARN + resp, err := client.Post("/api/v0/accounts", body, privilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusCreated)) + + data, err := resp.JSON() + Expect(err).NotTo(HaveOccurred()) + Expect(data["accountId"]).To(Equal(nonPrivilegedAccountID)) + Expect(data["privileged"]).To(BeFalse()) + }) + + It("should have bootstrapped the admin from adminArn", func() { + client.CallerARN = adminARN + resp, err := client.Get("/api/v0/authz/admins", nonPrivilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(string(resp.Body)).To(ContainSubstring(adminARN)) + }) + + It("should reject provisioning without adminArn for non-privileged accounts", func() { + body := map[string]interface{}{ + "accountId": "222222222222", + "privileged": false, + } + client.CallerARN = supervisorARN + resp, err := client.Post("/api/v0/accounts", body, privilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusBadRequest)) + Expect(string(resp.Body)).To(ContainSubstring("adminArn")) + }) + }) + + Context("Admin Management", Ordered, func() { + It("should allow admin to add another admin", func() { + client.CallerARN = adminARN + err := client.CreateAdmin(nonPrivilegedAccountID, "arn:aws:iam::111111111111:role/SecondAdmin") + Expect(err).NotTo(HaveOccurred()) + }) + + It("should list all admins for the account", func() { + client.CallerARN = adminARN + resp, err := client.Get("/api/v0/authz/admins", nonPrivilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + body := string(resp.Body) + Expect(body).To(ContainSubstring(adminARN)) + Expect(body).To(ContainSubstring("SecondAdmin")) + }) + + It("should deny non-admin from managing admins", func() { + client.CallerARN = nonAdminARN + resp, err := client.Post("/api/v0/authz/admins", map[string]interface{}{ + "principalArn": "arn:aws:iam::111111111111:role/Unauthorized", + }, nonPrivilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + }) + }) + + Context("Cedar Policy CRUD", Ordered, func() { + var policyID string + + It("should create a Cedar policy template", func() { + client.CallerARN = adminARN + var err error + policyID, err = client.CreatePolicy( + nonPrivilegedAccountID, + "clusters-read-only", + "Read-only access to clusters", + `permit(principal == ?principal, action in [ROSA::Action::"ListClusters", ROSA::Action::"DescribeCluster"], resource);`, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(policyID).NotTo(BeEmpty()) + GinkgoWriter.Printf("Created policy: %s\n", policyID) + }) + + It("should get the policy by ID", func() { + client.CallerARN = adminARN + resp, err := client.Get( + fmt.Sprintf("/api/v0/authz/policies/%s", policyID), + nonPrivilegedAccountID, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + data, err := resp.JSON() + Expect(err).NotTo(HaveOccurred()) + Expect(data["name"]).To(Equal("clusters-read-only")) + Expect(data["policyId"]).To(Equal(policyID)) + }) + + It("should list policies", func() { + client.CallerARN = adminARN + resp, err := client.Get("/api/v0/authz/policies", nonPrivilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(string(resp.Body)).To(ContainSubstring(policyID)) + }) + + It("should deny non-admin from creating policies", func() { + client.CallerARN = nonAdminARN + resp, err := client.Post("/api/v0/authz/policies", map[string]interface{}{ + "name": "unauthorized", + "policy": `permit(principal == ?principal, action == ROSA::Action::"ListClusters", resource);`, + }, nonPrivilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + }) + }) + + Context("Group and Attachment Management", Ordered, func() { + var ( + policyID string + groupID string + attachmentID string + ) + + BeforeAll(func() { + client.CallerARN = adminARN + + var err error + policyID, err = client.CreatePolicy( + nonPrivilegedAccountID, + "nodepool-management", + "Full nodepool management", + `permit(principal == ?principal, action in [ROSA::Action::"ListNodePools", ROSA::Action::"CreateNodePool", ROSA::Action::"DeleteNodePool", ROSA::Action::"DescribeNodePool"], resource);`, + ) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should create a group", func() { + var err error + groupID, err = client.CreateGroup(nonPrivilegedAccountID, "developers", "Developer team") + Expect(err).NotTo(HaveOccurred()) + Expect(groupID).NotTo(BeEmpty()) + GinkgoWriter.Printf("Created group: %s\n", groupID) + }) + + It("should attach policy to group", func() { + var err error + attachmentID, err = client.CreateAttachment(nonPrivilegedAccountID, policyID, "group", groupID) + Expect(err).NotTo(HaveOccurred()) + Expect(attachmentID).NotTo(BeEmpty()) + GinkgoWriter.Printf("Created attachment: %s\n", attachmentID) + }) + + It("should add a member to the group", func() { + err := client.AddGroupMembers(nonPrivilegedAccountID, groupID, []string{nonAdminARN}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should list group members", func() { + resp, err := client.Get( + fmt.Sprintf("/api/v0/authz/groups/%s/members", groupID), + nonPrivilegedAccountID, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(string(resp.Body)).To(ContainSubstring(nonAdminARN)) + }) + + It("should clean up attachment", func() { + err := client.DeleteAttachment(nonPrivilegedAccountID, attachmentID) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("Cedar Authorization Evaluation", Ordered, func() { + var ( + policyID string + groupID string + ) + + BeforeAll(func() { + client.CallerARN = adminARN + + var err error + policyID, err = client.CreatePolicy( + nonPrivilegedAccountID, + "cluster-list-only", + "Allow listing clusters only", + `permit(principal == ?principal, action == ROSA::Action::"ListClusters", resource);`, + ) + Expect(err).NotTo(HaveOccurred()) + + groupID, err = client.CreateGroup(nonPrivilegedAccountID, "authz-test-group", "Authz evaluation test group") + Expect(err).NotTo(HaveOccurred()) + + _, err = client.CreateAttachment(nonPrivilegedAccountID, policyID, "group", groupID) + Expect(err).NotTo(HaveOccurred()) + + err = client.AddGroupMembers(nonPrivilegedAccountID, groupID, []string{nonAdminARN}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should ALLOW ListClusters for authorized principal", func() { + decision, err := client.CheckAuthorization(nonPrivilegedAccountID, awstest.CheckAuthorizationRequest{ + Principal: nonAdminARN, + Action: "ListClusters", + Resource: "*", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(decision).To(Equal("ALLOW")) + }) + + It("should DENY CreateCluster for principal with only list access", func() { + decision, err := client.CheckAuthorization(nonPrivilegedAccountID, awstest.CheckAuthorizationRequest{ + Principal: nonAdminARN, + Action: "CreateCluster", + Resource: "*", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(decision).To(Equal("DENY")) + }) + + It("should DENY all actions for unknown principal", func() { + unknownARN := "arn:aws:iam::111111111111:role/UnknownRole" + decision, err := client.CheckAuthorization(nonPrivilegedAccountID, awstest.CheckAuthorizationRequest{ + Principal: unknownARN, + Action: "ListClusters", + Resource: "*", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(decision).To(Equal("DENY")) + }) + }) + + Context("ARN Normalization", Ordered, func() { + It("should normalize STS assumed-role ARN for admin check", func() { + stsARN := fmt.Sprintf("arn:aws:sts::%s:assumed-role/TestAdminRole/session-12345", nonPrivilegedAccountID) + client.CallerARN = stsARN + + resp, err := client.Get("/api/v0/authz/admins", nonPrivilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK), + "STS assumed-role ARN should be normalized to IAM role for admin check") + }) + + It("should normalize STS ARN for Cedar evaluation", func() { + client.CallerARN = adminARN + + // First create a policy and group for the IAM form of an ARN + policyID, err := client.CreatePolicy( + nonPrivilegedAccountID, + "arn-normalization-test", + "Test ARN normalization in Cedar", + `permit(principal == ?principal, action == ROSA::Action::"ListNodePools", resource);`, + ) + Expect(err).NotTo(HaveOccurred()) + + groupID, err := client.CreateGroup(nonPrivilegedAccountID, "arn-norm-group", "ARN normalization test") + Expect(err).NotTo(HaveOccurred()) + + _, err = client.CreateAttachment(nonPrivilegedAccountID, policyID, "group", groupID) + Expect(err).NotTo(HaveOccurred()) + + iamARN := "arn:aws:iam::111111111111:role/NormTestRole" + err = client.AddGroupMembers(nonPrivilegedAccountID, groupID, []string{iamARN}) + Expect(err).NotTo(HaveOccurred()) + + // Now check authorization using the STS form — should be normalized to IAM + stsARN := "arn:aws:sts::111111111111:assumed-role/NormTestRole/session-abc" + decision, err := client.CheckAuthorization(nonPrivilegedAccountID, awstest.CheckAuthorizationRequest{ + Principal: stsARN, + Action: "ListNodePools", + Resource: "*", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(decision).To(Equal("ALLOW"), + "STS assumed-role ARN should be normalized to IAM role for Cedar evaluation") + }) + }) + + Context("Admin Removal and Cedar Transition", Ordered, func() { + // Tests the flow: bootstrap admin sets up Cedar policies, then is removed. + // A second admin should still manage admins. A Cedar-authorized non-admin + // should still access resources but NOT manage admins (RequireAdmin is separate from Cedar). + const ( + secondAdminARN = "arn:aws:iam::111111111111:role/SecondAdmin" + cedarRoleARN = "arn:aws:iam::111111111111:role/CedarOnlyRole" + ) + + var groupID string + + BeforeAll(func() { + client.CallerARN = adminARN + + // Create a Cedar policy granting cluster access to cedarRoleARN + policyID, err := client.CreatePolicy( + nonPrivilegedAccountID, + "cedar-transition-test", + "Test Cedar access after admin removal", + `permit(principal == ?principal, action in [ROSA::Action::"ListClusters", ROSA::Action::"DescribeCluster"], resource);`, + ) + Expect(err).NotTo(HaveOccurred()) + + groupID, err = client.CreateGroup(nonPrivilegedAccountID, "cedar-transition-group", "Cedar transition test") + Expect(err).NotTo(HaveOccurred()) + + _, err = client.CreateAttachment(nonPrivilegedAccountID, policyID, "group", groupID) + Expect(err).NotTo(HaveOccurred()) + + err = client.AddGroupMembers(nonPrivilegedAccountID, groupID, []string{cedarRoleARN}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should verify Cedar-authorized role has resource access before admin removal", func() { + decision, err := client.CheckAuthorization(nonPrivilegedAccountID, awstest.CheckAuthorizationRequest{ + Principal: cedarRoleARN, + Action: "ListClusters", + Resource: "*", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(decision).To(Equal("ALLOW")) + }) + + It("should remove the bootstrap admin", func() { + client.CallerARN = adminARN + resp, err := client.Delete( + fmt.Sprintf("/api/v0/authz/admins/%s", adminARN), + nonPrivilegedAccountID, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(SatisfyAny(Equal(http.StatusOK), Equal(http.StatusNoContent))) + }) + + It("should deny removed admin from managing admins", func() { + client.CallerARN = adminARN + resp, err := client.Post("/api/v0/authz/admins", map[string]interface{}{ + "principalArn": "arn:aws:iam::111111111111:role/ShouldFail", + }, nonPrivilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden)) + }) + + It("should still allow second admin to manage admins", func() { + client.CallerARN = secondAdminARN + resp, err := client.Get("/api/v0/authz/admins", nonPrivilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("should still allow Cedar-authorized role to access resources after admin removal", func() { + decision, err := client.CheckAuthorization(nonPrivilegedAccountID, awstest.CheckAuthorizationRequest{ + Principal: cedarRoleARN, + Action: "ListClusters", + Resource: "*", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(decision).To(Equal("ALLOW")) + }) + + It("should deny Cedar-authorized role from managing admins (RequireAdmin is separate from Cedar)", func() { + client.CallerARN = cedarRoleARN + resp, err := client.Post("/api/v0/authz/admins", map[string]interface{}{ + "principalArn": "arn:aws:iam::111111111111:role/ShouldAlsoFail", + }, nonPrivilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden), + "Cedar policies cannot grant admin management — RequireAdmin checks the admins table, not Cedar") + }) + }) + + Context("Account Re-provisioning", Ordered, func() { + // Delete a customer account completely, re-create it with a new adminArn, + // and verify the full flow works again from scratch. + const ( + reproAccountID = "333333333333" + reproAdminARN = "arn:aws:iam::333333333333:role/OriginalAdmin" + reproNewAdmin = "arn:aws:iam::333333333333:role/FreshAdmin" + reproAppRole = "arn:aws:iam::333333333333:role/AppRole" + ) + + It("should provision the account the first time", func() { + client.CallerARN = supervisorARN + resp, err := client.Post("/api/v0/accounts", map[string]interface{}{ + "accountId": reproAccountID, + "privileged": false, + "adminArn": reproAdminARN, + }, privilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusCreated)) + }) + + It("should allow the bootstrapped admin to create a policy", func() { + client.CallerARN = reproAdminARN + _, err := client.CreatePolicy( + reproAccountID, + "repro-cluster-access", + "Cluster access for re-provisioning test", + `permit(principal == ?principal, action == ROSA::Action::"ListClusters", resource);`, + ) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should delete the account completely", func() { + client.CallerARN = supervisorARN + resp, err := client.Delete( + fmt.Sprintf("/api/v0/accounts/%s", reproAccountID), + privilegedAccountID, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(SatisfyAny(Equal(http.StatusOK), Equal(http.StatusNoContent))) + }) + + It("should confirm the account no longer exists", func() { + client.CallerARN = supervisorARN + resp, err := client.Get( + fmt.Sprintf("/api/v0/accounts/%s", reproAccountID), + privilegedAccountID, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusNotFound)) + }) + + It("should re-provision the account with a new adminArn", func() { + client.CallerARN = supervisorARN + resp, err := client.Post("/api/v0/accounts", map[string]interface{}{ + "accountId": reproAccountID, + "privileged": false, + "adminArn": reproNewAdmin, + }, privilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusCreated)) + }) + + It("should deny the old admin after re-provisioning", func() { + client.CallerARN = reproAdminARN + resp, err := client.Get("/api/v0/authz/admins", reproAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusForbidden), + "old admin from previous provisioning should not have access") + }) + + It("should allow the new admin to manage the account", func() { + client.CallerARN = reproNewAdmin + resp, err := client.Get("/api/v0/authz/admins", reproAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + Expect(string(resp.Body)).To(ContainSubstring(reproNewAdmin)) + }) + + It("should allow the new admin to create policies", func() { + client.CallerARN = reproNewAdmin + _, err := client.CreatePolicy( + reproAccountID, + "fresh-cluster-access", + "Fresh cluster access after re-provisioning", + `permit(principal == ?principal, action == ROSA::Action::"ListClusters", resource);`, + ) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should allow the new admin to create groups and attach policies", func() { + client.CallerARN = reproNewAdmin + + policyID, err := client.CreatePolicy( + reproAccountID, + "fresh-nodepool-access", + "NodePool access after re-provisioning", + `permit(principal == ?principal, action == ROSA::Action::"ListNodePools", resource);`, + ) + Expect(err).NotTo(HaveOccurred()) + + groupID, err := client.CreateGroup(reproAccountID, "fresh-group", "Re-provisioned group") + Expect(err).NotTo(HaveOccurred()) + + _, err = client.CreateAttachment(reproAccountID, policyID, "group", groupID) + Expect(err).NotTo(HaveOccurred()) + + err = client.AddGroupMembers(reproAccountID, groupID, []string{reproAppRole}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should enforce Cedar policies on the re-provisioned account", func() { + decision, err := client.CheckAuthorization(reproAccountID, awstest.CheckAuthorizationRequest{ + Principal: reproAppRole, + Action: "ListNodePools", + Resource: "*", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(decision).To(Equal("ALLOW")) + + decision, err = client.CheckAuthorization(reproAccountID, awstest.CheckAuthorizationRequest{ + Principal: reproAppRole, + Action: "DeleteCluster", + Resource: "*", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(decision).To(Equal("DENY")) + }) + }) + + Context("Privileged Account Bypass", func() { + It("should bypass Cedar for privileged account", func() { + client.CallerARN = fmt.Sprintf("arn:aws:iam::%s:user/privileged-user", privilegedAccountID) + + resp, err := client.Get("/api/v0/authz/admins", privilegedAccountID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + }) +}) diff --git a/test/e2e-authz-local/suite_test.go b/test/e2e-authz-local/suite_test.go new file mode 100644 index 00000000..6d3bc31c --- /dev/null +++ b/test/e2e-authz-local/suite_test.go @@ -0,0 +1,13 @@ +package authzlocal_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAuthzLocal(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Local Authz E2E Suite") +}