Skip to content
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Custom `SCAAccessService` follows SDK conventions:
- Create client via `isp.FromISPAuth(ispAuth, "sca", ".", "", refreshCallback)`
- Set `X-API-Version: 2.0` header on all requests
- `httpClient` interface for DI/testing
- The service slug (`"sca"` / `"uar"`) is what `isp.FromISPAuth` resolves into the live host, and the retry/header tests overwrite `client.BaseURL` before issuing a request — so it is pinned separately, by asserting the constructed `BaseURL` against the fake-JWT tenant (`TestNewSCAAccessService_UsesSCAServiceSlug`, `TestNewAccessRequestService_UsesUARServiceSlug`) **before** any swap. Assert it before mutating `BaseURL`, never after
- Wire contracts are asserted on what is *sent*: the `mockHTTPClient` in both packages records `gotRoute`/`gotBody`/`gotParams` before dispatching. Assert the exact route and the full body contents — "non-nil body" lets a nil payload through, and a canned response tells you nothing about the request

## SCA Access API
- **Base URL:** `https://{subdomain}.sca.{platform_domain}/api`
Expand Down
134 changes: 80 additions & 54 deletions docs/mutation-ledger.md

Large diffs are not rendered by default.

47 changes: 39 additions & 8 deletions internal/sca/models/credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package models

import (
"encoding/json"
"strings"
"testing"
)

Expand Down Expand Up @@ -54,16 +55,31 @@ func TestParseAWSCredentials(t *testing.T) {
name string
input string
wantErr bool
// Asserted only when wantErr is true and non-empty. Without it the
// empty-string guard is inert: json.Unmarshal rejects "" anyway, so
// deleting the guard changes only the message.
wantErrContains string
// Asserted only when wantErr is false. Without these the parser could
// swap SecretAccessKey and SessionToken and this package would not
// notice — it discarded the parsed value entirely.
wantAccessKeyID string
wantSecretKey string
wantSessionToken string
}{
{
name: "valid JSON string",
input: `{"aws_access_key":"AKIA","aws_secret_access_key":"secret","aws_session_token":"token"}`,
wantErr: false,
name: "valid JSON string",
// Distinguishable values: a swap of any two must change the result.
input: `{"aws_access_key":"AKIAVALUE","aws_secret_access_key":"SECRETVALUE","aws_session_token":"TOKENVALUE"}`,
wantErr: false,
wantAccessKeyID: "AKIAVALUE",
wantSecretKey: "SECRETVALUE",
wantSessionToken: "TOKENVALUE",
},
{
name: "empty string",
input: "",
wantErr: true,
name: "empty string",
input: "",
wantErr: true,
wantErrContains: "empty credentials string",
},
{
name: "malformed JSON",
Expand All @@ -85,9 +101,24 @@ func TestParseAWSCredentials(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := ParseAWSCredentials(tt.input)
creds, err := ParseAWSCredentials(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseAWSCredentials() error = %v, wantErr %v", err, tt.wantErr)
t.Fatalf("ParseAWSCredentials() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
if tt.wantErrContains != "" && !strings.Contains(err.Error(), tt.wantErrContains) {
t.Errorf("error = %v, want it to contain %q", err, tt.wantErrContains)
}
return
}
if creds.AccessKeyID != tt.wantAccessKeyID {
t.Errorf("AccessKeyID = %q, want %q", creds.AccessKeyID, tt.wantAccessKeyID)
}
if creds.SecretAccessKey != tt.wantSecretKey {
t.Errorf("SecretAccessKey = %q, want %q", creds.SecretAccessKey, tt.wantSecretKey)
}
if creds.SessionToken != tt.wantSessionToken {
t.Errorf("SessionToken = %q, want %q", creds.SessionToken, tt.wantSessionToken)
}
})
}
Expand Down
238 changes: 238 additions & 0 deletions internal/sca/models/wire_tags_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
package models

import (
"encoding/json"
"testing"
)

// TestElevateRequest_JSONTags pins the request-body field names. The existing
// marshal tests decode into the same Go struct, so a renamed tag round-trips
// trivially; these assert the literal keys the API sees.
func TestElevateRequest_JSONTags(t *testing.T) {
t.Parallel()

req := ElevateRequest{
CSP: CSPAzure,
OrganizationID: "org-tag-1",
Targets: []ElevateTarget{
{WorkspaceID: "ws-tag-2", RoleID: "role-tag-3", RoleName: "Role Tag Four"},
},
}
b, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal: %v", err)
}

var raw map[string]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, key := range []string{"csp", "organizationId", "targets"} {
if _, ok := raw[key]; !ok {
t.Errorf("request body is missing key %q: %s", key, b)
}
}

var targets []map[string]json.RawMessage
if err := json.Unmarshal(raw["targets"], &targets); err != nil {
t.Fatalf("unmarshal targets: %v", err)
}
if len(targets) != 1 {
t.Fatalf("targets len = %d, want 1", len(targets))
}
for _, key := range []string{"workspaceId", "roleId", "roleName"} {
if _, ok := targets[0][key]; !ok {
t.Errorf("target is missing key %q: %s", key, b)
}
}
}

// TestElevateResponse_DecodesPopulatedAccessCredentials is the guard for the
// single field `grant env` exists to deliver. Every prior test only ever saw
// "accessCredentials": null, or marshaled a Go struct whose field was nil, so
// renaming the tag passed the entire repo suite while every AWS elevation
// silently returned no credentials.
func TestElevateResponse_DecodesPopulatedAccessCredentials(t *testing.T) {
t.Parallel()

// Distinguishable values so a swap in the parser cannot be masked.
const wire = `{
"response": {
"csp": "AWS",
"organizationId": "org-creds-1",
"results": [
{
"workspaceId": "111122223333",
"roleId": "arn:aws:iam::111122223333:role/Admin",
"sessionId": "sess-creds-2",
"accessCredentials": "{\"aws_access_key\":\"ACCESSKEYVALUE\",\"aws_secret_access_key\":\"SECRETKEYVALUE\",\"aws_session_token\":\"SESSIONTOKENVALUE\"}",
"errorInfo": null
}
]
}
}`

var resp ElevateResponse
if err := json.Unmarshal([]byte(wire), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(resp.Response.Results) != 1 {
t.Fatalf("results len = %d, want 1", len(resp.Response.Results))
}
raw := resp.Response.Results[0].AccessCredentials
if raw == nil {
t.Fatal("accessCredentials did not decode: got nil, want the populated credentials string")
}

creds, err := ParseAWSCredentials(*raw)
if err != nil {
t.Fatalf("ParseAWSCredentials: %v", err)
}
if creds.AccessKeyID != "ACCESSKEYVALUE" {
t.Errorf("AccessKeyID = %q, want %q", creds.AccessKeyID, "ACCESSKEYVALUE")
}
if creds.SecretAccessKey != "SECRETKEYVALUE" {
t.Errorf("SecretAccessKey = %q, want %q", creds.SecretAccessKey, "SECRETKEYVALUE")
}
if creds.SessionToken != "SESSIONTOKENVALUE" {
t.Errorf("SessionToken = %q, want %q", creds.SessionToken, "SESSIONTOKENVALUE")
}
}

// TestElevateResponse_AccessCredentialsAbsentOrEmpty covers the non-populated
// shapes of the same field.
func TestElevateResponse_AccessCredentialsAbsentOrEmpty(t *testing.T) {
t.Parallel()

tests := []struct {
name string
result string
wantNil bool
wantParseErr bool
}{
{name: "explicit null", result: `{"accessCredentials": null}`, wantNil: true},
{name: "absent", result: `{"sessionId": "sess-1"}`, wantNil: true},
{name: "empty string", result: `{"accessCredentials": ""}`, wantParseErr: true},
{name: "malformed inner JSON", result: `{"accessCredentials": "{not json}"}`, wantParseErr: true},
{name: "incomplete inner JSON", result: `{"accessCredentials": "{\"aws_access_key\":\"AK\"}"}`, wantParseErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var res ElevateTargetResult
if err := json.Unmarshal([]byte(tt.result), &res); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if tt.wantNil {
if res.AccessCredentials != nil {
t.Fatalf("accessCredentials = %q, want nil", *res.AccessCredentials)
}
return
}
if res.AccessCredentials == nil {
t.Fatal("accessCredentials = nil, want a decoded string")
}
_, err := ParseAWSCredentials(*res.AccessCredentials)
if (err != nil) != tt.wantParseErr {
t.Errorf("ParseAWSCredentials error = %v, wantErr %v", err, tt.wantParseErr)
}
})
}
}

// TestGroupsElevateRequest_JSONTags is the twin of TestElevateRequest_JSONTags
// for the group-elevation request body. The nested `groupId` inside `targets`
// is the entire per-target payload of POST /api/access/elevate/groups: if it
// were renamed, every `grant --group` elevation would send
// {"targets":[{"ZgroupId":"..."}]} and group elevation would be broken outright.
func TestGroupsElevateRequest_JSONTags(t *testing.T) {
t.Parallel()

// Distinguishable values so a swap between fields cannot be masked.
req := GroupsElevateRequest{
DirectoryID: "dir-groups-1",
CSP: CSPAzure,
Targets: []GroupsElevateTarget{
{GroupID: "group-groups-2"},
},
}
b, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal: %v", err)
}

var raw map[string]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, key := range []string{"directoryId", "csp", "targets"} {
if _, ok := raw[key]; !ok {
t.Errorf("request body is missing key %q: %s", key, b)
}
}
if string(raw["directoryId"]) != `"dir-groups-1"` {
t.Errorf("directoryId = %s, want %q", raw["directoryId"], "dir-groups-1")
}
if string(raw["csp"]) != `"AZURE"` {
t.Errorf("csp = %s, want %q", raw["csp"], "AZURE")
}

var targets []map[string]json.RawMessage
if err := json.Unmarshal(raw["targets"], &targets); err != nil {
t.Fatalf("unmarshal targets: %v", err)
}
if len(targets) != 1 {
t.Fatalf("targets len = %d, want 1", len(targets))
}
got, ok := targets[0]["groupId"]
if !ok {
t.Fatalf("target is missing key %q: %s", "groupId", b)
}
if string(got) != `"group-groups-2"` {
t.Errorf("targets[0].groupId = %s, want %q", got, "group-groups-2")
}
}

// TestGroupsElevateResponse_DecodesPopulatedResult pins the response side of
// group elevation. Every prior test marshaled a Go struct or decoded a body
// produced from one, so renaming a tag round-tripped trivially. sessionId is
// the ID `grant --group` reports and the only handle for revoking the session
// by ID: if it broke, elevation appears to succeed while grant prints an empty
// session ID and the session can never be revoked.
func TestGroupsElevateResponse_DecodesPopulatedResult(t *testing.T) {
t.Parallel()

// Distinguishable values so a swap between fields cannot be masked.
const wire = `{
"directoryId": "dir-groupsresp-1",
"csp": "AZURE",
"results": [
{
"groupId": "group-groupsresp-2",
"sessionId": "sess-groupsresp-3",
"errorInfo": null
}
]
}`

var resp GroupsElevateResponse
if err := json.Unmarshal([]byte(wire), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.DirectoryID != "dir-groupsresp-1" {
t.Errorf("directoryId = %q, want %q", resp.DirectoryID, "dir-groupsresp-1")
}
if resp.CSP != CSPAzure {
t.Errorf("csp = %q, want %q", resp.CSP, CSPAzure)
}
if len(resp.Results) != 1 {
t.Fatalf("results len = %d, want 1", len(resp.Results))
}
if resp.Results[0].GroupID != "group-groupsresp-2" {
t.Errorf("results[0].groupId = %q, want %q", resp.Results[0].GroupID, "group-groupsresp-2")
}
if resp.Results[0].SessionID != "sess-groupsresp-3" {
t.Errorf("results[0].sessionId = %q, want %q", resp.Results[0].SessionID, "sess-groupsresp-3")
}
}
73 changes: 73 additions & 0 deletions internal/sca/service_client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package sca

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/cyberark/idsec-sdk-golang/pkg/common/isp"
)

// ispClientFromService reaches through the logging decorator to the SDK client
// the constructor actually published.
func ispClientFromService(t *testing.T, svc *SCAAccessService) *isp.IdsecISPServiceClient {
t.Helper()
lc, ok := svc.httpClient.(*loggingClient)
if !ok {
t.Fatalf("httpClient is %T, want *loggingClient", svc.httpClient)
}
client, ok := lc.inner.(*isp.IdsecISPServiceClient)
if !ok {
t.Fatalf("inner client is %T, want *isp.IdsecISPServiceClient", lc.inner)
}
return client
}

// TestNewSCAAccessService_SetsAPIVersionHeader is the standalone guard for the
// X-API-Version header. It supersedes — but does not remove — the two-line
// assertion inside TestNewSCAAccessServiceDisablesTransientRetry, which remains
// as deliberate redundancy. Without this standalone test, a retry-motivated
// rename or deletion of that test would have dropped the header guard silently.
func TestNewSCAAccessService_SetsAPIVersionHeader(t *testing.T) {
gotAPIVersion := make(chan string, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAPIVersion <- r.Header.Get("X-API-Version")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
}))
defer srv.Close()

svc, err := NewSCAAccessService(ispAuthWithToken(t))
if err != nil {
t.Fatalf("NewSCAAccessService: %v", err)
}
ispClientFromService(t, svc).BaseURL = srv.URL

resp, err := svc.httpClient.Get(t.Context(), "/api/access/sessions", nil)
if err != nil {
t.Fatalf("Get: %v", err)
}
defer func() { _ = resp.Body.Close() }()

if got := <-gotAPIVersion; got != "2.0" {
t.Errorf("X-API-Version = %q, want %q", got, "2.0")
}
}

// TestNewSCAAccessService_UsesSCAServiceSlug pins the "sca" service slug passed
// to isp.FromISPAuth. The retry and header tests both overwrite BaseURL before
// issuing a request, so without this the slug could be changed to anything and
// no test in the repo would notice — while every live request would go to the
// wrong host.
func TestNewSCAAccessService_UsesSCAServiceSlug(t *testing.T) {
svc, err := NewSCAAccessService(ispAuthWithToken(t))
if err != nil {
t.Fatalf("NewSCAAccessService: %v", err)
}

// The fake JWT carries subdomain=testtenant, platform_domain=example.test.
const want = "https://testtenant.sca.example.test"
if got := ispClientFromService(t, svc).BaseURL; got != want {
t.Errorf("BaseURL = %q, want %q", got, want)
}
}
Loading
Loading