From 728a8ab70a2aad4da48a1f3614330cddbabe1f48 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:00:01 +0800 Subject: [PATCH] feat(forward): support SA token issuance and revocation --- CHANGELOG.md | 4 + Makefile | 5 +- README.md | 58 ++++ docs/api/reference.md | 341 ++++++++++++++++++++-- examples/auth/forward-tokens/main.go | 81 +++++ forward/client.go | 64 ++-- forward/client_test.go | 4 +- forward/identity.go | 9 +- forward/identityserviceaccounttoken.go | 35 +++ forward/serviceaccounttoken.go | 153 ++++++++++ forward/serviceaccounttoken_test.go | 204 +++++++++++++ forward/test_helpers_test.go | 14 +- forward/testdata/api-contracts.json | 59 ++++ forward/testdata/api-operation-cases.json | 66 ++++- integration/sa_tokens_live_test.go | 111 +++++++ internal/testsupport/contracts.go | 3 + 16 files changed, 1150 insertions(+), 61 deletions(-) create mode 100644 examples/auth/forward-tokens/main.go create mode 100644 forward/identityserviceaccounttoken.go create mode 100644 forward/serviceaccounttoken.go create mode 100644 forward/serviceaccounttoken_test.go create mode 100644 integration/sa_tokens_live_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c3fe85..6161608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ existing `0.1.0` release; earlier development prereleases are not listed. ## [Unreleased] +### Added + +- Forward admin and Identity SAT issuance and Forward SAT revocation, using explicit per-call SA Key authentication, typed issuance/revocation metadata, and no automatic retries. Existing platform credentials and PAT/SAT business authentication remain unchanged. + ## [0.4.0] - 2026-10-12 ### Added diff --git a/Makefile b/Makefile index 612b48d..c45bbb3 100644 --- a/Makefile +++ b/Makefile @@ -124,9 +124,12 @@ test-e2e: test-unit -json -run 'E2ELive$$' ./forward ./managed > build/test-results/e2e.json; \ result=$$?; echo "E2E result: build/test-results/e2e.json (exit $$result)"; exit $$result -.PHONY: test-live-sa test-live-sa-execution +.PHONY: test-live-sa test-live-sa-execution test-live-sa-tokens test-live-sa: LIVE_ENV_FILE="$(abspath $(LIVE_ENV_FILE))" go test -tags live -count=1 -v -run TestServiceAccountReadOnlyLive -timeout=5m ./integration test-live-sa-execution: LIVE_ENV_FILE="$(abspath $(LIVE_ENV_FILE))" go test -tags live -count=1 -v -run TestServiceAccountExecutionLive -timeout=15m ./integration + +test-live-sa-tokens: + LIVE_ENV_FILE="$(abspath $(LIVE_ENV_FILE))" go test -tags live -count=1 -v -run '^TestForwardServiceAccountTokensLive$$' -timeout=10m ./integration diff --git a/README.md b/README.md index c8d6a10..9c0ebb4 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,64 @@ cleanup verification, run `make test-live-sa-execution` with independent; `QODER_AUTH_MODE` switches examples, not the PAT test suites. See the [official authentication contract](https://docs.qoder.com/cloud-agents/api/conventions/authentication). +### Forward SAT management + +Forward exposes explicit admin/Identity SAT issuance and revocation in addition +to automatic platform exchange. These three endpoints authenticate with an +**SA Key**, supplied per method call; ordinary business requests still use PAT +or SAT. The Key overrides client/request Authorization only for that call, is +never included in the JSON body, and is not saved on the client. + +`ttl_seconds` is optional (1-43200); omission uses the issuer's default. Issuance +and revocation are not automatically retried, even when the client or request +allows retries. Do not blindly repeat an issuance after an uncertain network +failure: it may already have issued a token. Metadata is for audit information, +not secrets. Keep the issuance response's Token ID, Credential ID and expiry for +precise revocation. A returned static SAT does not renew automatically, and +revocation affects its Forward binding; it does not cancel an already-running +Session or revoke unrelated Managed/platform access. Never log the SA Key, SAT, +serialized token response or Authorization headers. + +Run `make test-live-sa-tokens` with `QODER_SA_CN_KEY`, +`QODER_SA_GLOBAL_KEY` and `QODER_SA_LIVE_ALLOW_WRITE=true` to verify both regions. +This separate suite issues admin and Identity SATs, checks business access and +cross-Identity refusal, then revokes the tokens, verifies access is refused and +deletes its temporary Identities. It does not execute a model or require +`QODER_SA_LIVE_ALLOW_EXECUTION=true`. + +```go +issuer := forward.NewClient(option.WithBaseURL(forwardBaseURL)) +// Admin: issuer.ServiceAccountTokens.New(ctx, the same parameters). +token, err := issuer.Identities.ServiceAccountTokens.New(ctx, identityID, + forward.ServiceAccountTokenNewParams{SAKey: saKey, TTLSeconds: forward.Int(600)}) +if err != nil { return err } +defer func() { + // Use an independent cleanup context if the business context has expired. + _, revokeErr := issuer.ServiceAccountTokens.Revoke(context.Background(), token.AuthTokenID, + forward.ServiceAccountTokenRevokeParams{ + SAKey: saKey, + TargetCredentialID: forward.String(token.CredentialID), + TargetExpiresAt: param.NewOpt(token.ExpiresAt), + Reason: forward.String("runtime complete"), + }) + // Report cleanup failures; do not log credentials. + if revokeErr != nil { log.Printf("Forward SAT cleanup failed (%T)", revokeErr) } +}() +runtime := forward.NewClient(option.WithBaseURL(forwardBaseURL), + option.WithAuthMode("sa"), option.WithAuthToken(token.AccessToken)) +page, err := runtime.Sessions.List(ctx, forward.SessionListParams{ + IdentityIDs: []string{identityID}, Limit: forward.Int(1), +}) +``` + +Runnable example: `go run ./examples/auth/forward-tokens`. It revokes its token +even if the business call fails. Set `QODER_SA_KEY` and `QODER_SA_REGION=cn|global`; +set `QODER_IDENTITY_ID` for Identity mode, or leave it unset for admin mode. + +See the official [admin issuance](https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create), +[Identity issuance](https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create-identity) +and [revocation](https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/revoke) contracts. + ## Usage and cancellation Forward Usage requires PAT or Admin SAT. diff --git a/docs/api/reference.md b/docs/api/reference.md index 198687a..62330c1 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -282,6 +282,9 @@ import "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward" - [func \(r \*IdentityService\) New\(ctx context.Context, params IdentityNewParams, opts ...option.RequestOption\) \(res \*Identity, err error\)](<#IdentityService.New>) - [func \(r \*IdentityService\) Stats\(ctx context.Context, opts ...option.RequestOption\) \(res \*IdentityStats, err error\)](<#IdentityService.Stats>) - [func \(r \*IdentityService\) Update\(ctx context.Context, identityID string, params IdentityUpdateParams, opts ...option.RequestOption\) \(res \*Identity, err error\)](<#IdentityService.Update>) +- [type IdentityServiceAccountTokenService](<#IdentityServiceAccountTokenService>) + - [func NewIdentityServiceAccountTokenService\(opts ...option.RequestOption\) IdentityServiceAccountTokenService](<#NewIdentityServiceAccountTokenService>) + - [func \(r \*IdentityServiceAccountTokenService\) New\(ctx context.Context, identityID string, params ServiceAccountTokenNewParams, opts ...option.RequestOption\) \(res \*ServiceAccountToken, err error\)](<#IdentityServiceAccountTokenService.New>) - [type IdentityStats](<#IdentityStats>) - [func \(r IdentityStats\) RawJSON\(\) string](<#IdentityStats.RawJSON>) - [func \(r \*IdentityStats\) UnmarshalJSON\(data \[\]byte\) error](<#IdentityStats.UnmarshalJSON>) @@ -489,6 +492,28 @@ import "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward" - [type ScheduleUpdateParams](<#ScheduleUpdateParams>) - [func \(r ScheduleUpdateParams\) MarshalJSON\(\) \(\[\]byte, error\)](<#ScheduleUpdateParams.MarshalJSON>) - [func \(r \*ScheduleUpdateParams\) UnmarshalJSON\(data \[\]byte\) error](<#ScheduleUpdateParams.UnmarshalJSON>) +- [type ServiceAccountToken](<#ServiceAccountToken>) + - [func \(r ServiceAccountToken\) GoString\(\) string](<#ServiceAccountToken.GoString>) + - [func \(r ServiceAccountToken\) RawJSON\(\) string](<#ServiceAccountToken.RawJSON>) + - [func \(r ServiceAccountToken\) String\(\) string](<#ServiceAccountToken.String>) + - [func \(r \*ServiceAccountToken\) UnmarshalJSON\(data \[\]byte\) error](<#ServiceAccountToken.UnmarshalJSON>) +- [type ServiceAccountTokenNewParams](<#ServiceAccountTokenNewParams>) + - [func \(r ServiceAccountTokenNewParams\) GoString\(\) string](<#ServiceAccountTokenNewParams.GoString>) + - [func \(r ServiceAccountTokenNewParams\) MarshalJSON\(\) \(\[\]byte, error\)](<#ServiceAccountTokenNewParams.MarshalJSON>) + - [func \(r ServiceAccountTokenNewParams\) String\(\) string](<#ServiceAccountTokenNewParams.String>) + - [func \(r \*ServiceAccountTokenNewParams\) UnmarshalJSON\(data \[\]byte\) error](<#ServiceAccountTokenNewParams.UnmarshalJSON>) +- [type ServiceAccountTokenRevokeParams](<#ServiceAccountTokenRevokeParams>) + - [func \(r ServiceAccountTokenRevokeParams\) GoString\(\) string](<#ServiceAccountTokenRevokeParams.GoString>) + - [func \(r ServiceAccountTokenRevokeParams\) MarshalJSON\(\) \(\[\]byte, error\)](<#ServiceAccountTokenRevokeParams.MarshalJSON>) + - [func \(r ServiceAccountTokenRevokeParams\) String\(\) string](<#ServiceAccountTokenRevokeParams.String>) + - [func \(r \*ServiceAccountTokenRevokeParams\) UnmarshalJSON\(data \[\]byte\) error](<#ServiceAccountTokenRevokeParams.UnmarshalJSON>) +- [type ServiceAccountTokenRevokeResponse](<#ServiceAccountTokenRevokeResponse>) + - [func \(r ServiceAccountTokenRevokeResponse\) RawJSON\(\) string](<#ServiceAccountTokenRevokeResponse.RawJSON>) + - [func \(r \*ServiceAccountTokenRevokeResponse\) UnmarshalJSON\(data \[\]byte\) error](<#ServiceAccountTokenRevokeResponse.UnmarshalJSON>) +- [type ServiceAccountTokenService](<#ServiceAccountTokenService>) + - [func NewServiceAccountTokenService\(opts ...option.RequestOption\) ServiceAccountTokenService](<#NewServiceAccountTokenService>) + - [func \(r \*ServiceAccountTokenService\) New\(ctx context.Context, params ServiceAccountTokenNewParams, opts ...option.RequestOption\) \(res \*ServiceAccountToken, err error\)](<#ServiceAccountTokenService.New>) + - [func \(r \*ServiceAccountTokenService\) Revoke\(ctx context.Context, authTokenID string, params ServiceAccountTokenRevokeParams, opts ...option.RequestOption\) \(res \*ServiceAccountTokenRevokeResponse, err error\)](<#ServiceAccountTokenService.Revoke>) - [type Session](<#Session>) - [func \(r Session\) RawJSON\(\) string](<#Session.RawJSON>) - [func \(r \*Session\) UnmarshalJSON\(data \[\]byte\) error](<#Session.UnmarshalJSON>) @@ -2021,22 +2046,23 @@ Client exposes Forward resources. Configure options before sharing a client. ```go type Client struct { - Options []option.RequestOption - Templates TemplateService - Identities IdentityService - Sessions SessionService - Schedules ScheduleService - ScheduleRuns ScheduleRunService - Batches BatchService - Channels ChannelService - ChannelPairings ChannelPairingService - Environments EnvironmentService - Files FileService - Skills SkillService - Vaults VaultService - MemoryStores MemoryStoreService - Usage UsageService - Models ModelService + Options []option.RequestOption + Templates TemplateService + Identities IdentityService + Sessions SessionService + Schedules ScheduleService + ScheduleRuns ScheduleRunService + Batches BatchService + Channels ChannelService + ChannelPairings ChannelPairingService + Environments EnvironmentService + Files FileService + Skills SkillService + Vaults VaultService + MemoryStores MemoryStoreService + Usage UsageService + Models ModelService + ServiceAccountTokens ServiceAccountTokenService } ``` @@ -4120,9 +4146,10 @@ IdentityService provides Forward Identity operations. ```go type IdentityService struct { - Options []option.RequestOption - Configs IdentityConfigService - MemoryStores IdentityMemoryStoreService + Options []option.RequestOption + Configs IdentityConfigService + MemoryStores IdentityMemoryStoreService + ServiceAccountTokens IdentityServiceAccountTokenService } ``` @@ -4243,6 +4270,35 @@ func (r *IdentityService) Update(ctx context.Context, identityID string, params Update Identity + +## type [IdentityServiceAccountTokenService]() + +IdentityServiceAccountTokenService issues SATs bound to a Forward Identity. + +```go +type IdentityServiceAccountTokenService struct { + Options []option.RequestOption +} +``` + + +### func [NewIdentityServiceAccountTokenService]() + +```go +func NewIdentityServiceAccountTokenService(opts ...option.RequestOption) IdentityServiceAccountTokenService +``` + + + + +### func \(\*IdentityServiceAccountTokenService\) [New]() + +```go +func (r *IdentityServiceAccountTokenService) New(ctx context.Context, identityID string, params ServiceAccountTokenNewParams, opts ...option.RequestOption) (res *ServiceAccountToken, err error) +``` + +New issues an Identity SAT using the explicit SA Key. Automatic retries are disabled. + ## type [IdentityStats]() @@ -6844,6 +6900,253 @@ func (r *ScheduleUpdateParams) UnmarshalJSON(data []byte) error + +## type [ServiceAccountToken]() + +ServiceAccountToken contains a newly issued Forward SAT. Treat AccessToken and RawJSON as secrets. Use WithAuthMode\("sa"\) and WithAuthToken\(AccessToken\) for business calls; renewal is caller\-owned. + +```go +type ServiceAccountToken struct { + Type string `json:"type"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + ExpiresAt time.Time `json:"expires_at" format:"date-time"` + AuthTokenID string `json:"auth_token_id"` + ServiceAccountID string `json:"service_account_id"` + CredentialID string `json:"credential_id"` + // admin or identity. IdentityID is absent for admin tokens. + SubjectType string `json:"subject_type"` + IdentityID string `json:"identity_id"` + JSON struct { + Type respjson.Field + AccessToken respjson.Field + TokenType respjson.Field + ExpiresIn respjson.Field + ExpiresAt respjson.Field + AuthTokenID respjson.Field + ServiceAccountID respjson.Field + CredentialID respjson.Field + SubjectType respjson.Field + IdentityID respjson.Field + ExtraFields map[string]respjson.Field + // contains filtered or unexported fields + } `json:"-"` +} +``` + + +### func \(ServiceAccountToken\) [GoString]() + +```go +func (r ServiceAccountToken) GoString() string +``` + + + + +### func \(ServiceAccountToken\) [RawJSON]() + +```go +func (r ServiceAccountToken) RawJSON() string +``` + + + + +### func \(ServiceAccountToken\) [String]() + +```go +func (r ServiceAccountToken) String() string +``` + + + + +### func \(\*ServiceAccountToken\) [UnmarshalJSON]() + +```go +func (r *ServiceAccountToken) UnmarshalJSON(data []byte) error +``` + + + + +## type [ServiceAccountTokenNewParams]() + + + +```go +type ServiceAccountTokenNewParams struct { + // SA Key used only in this request's Authorization header; never serialized as JSON. + SAKey string `json:"-"` + // Lifetime in seconds, 1-43200. Omit to use the issuer's default. + TTLSeconds param.Opt[int64] `json:"ttl_seconds,omitzero"` + // Business audit metadata; do not include credentials or other secrets. + Metadata map[string]any `json:"metadata,omitzero"` + // contains filtered or unexported fields +} +``` + + +### func \(ServiceAccountTokenNewParams\) [GoString]() + +```go +func (r ServiceAccountTokenNewParams) GoString() string +``` + + + + +### func \(ServiceAccountTokenNewParams\) [MarshalJSON]() + +```go +func (r ServiceAccountTokenNewParams) MarshalJSON() ([]byte, error) +``` + + + + +### func \(ServiceAccountTokenNewParams\) [String]() + +```go +func (r ServiceAccountTokenNewParams) String() string +``` + + + + +### func \(\*ServiceAccountTokenNewParams\) [UnmarshalJSON]() + +```go +func (r *ServiceAccountTokenNewParams) UnmarshalJSON(data []byte) error +``` + + + + +## type [ServiceAccountTokenRevokeParams]() + + + +```go +type ServiceAccountTokenRevokeParams struct { + SAKey string `json:"-"` + TargetCredentialID param.Opt[string] `json:"target_credential_id,omitzero"` + TargetExpiresAt param.Opt[time.Time] `json:"target_expires_at,omitzero" format:"date-time"` + Reason param.Opt[string] `json:"reason,omitzero"` + // contains filtered or unexported fields +} +``` + + +### func \(ServiceAccountTokenRevokeParams\) [GoString]() + +```go +func (r ServiceAccountTokenRevokeParams) GoString() string +``` + + + + +### func \(ServiceAccountTokenRevokeParams\) [MarshalJSON]() + +```go +func (r ServiceAccountTokenRevokeParams) MarshalJSON() ([]byte, error) +``` + + + + +### func \(ServiceAccountTokenRevokeParams\) [String]() + +```go +func (r ServiceAccountTokenRevokeParams) String() string +``` + + + + +### func \(\*ServiceAccountTokenRevokeParams\) [UnmarshalJSON]() + +```go +func (r *ServiceAccountTokenRevokeParams) UnmarshalJSON(data []byte) error +``` + + + + +## type [ServiceAccountTokenRevokeResponse]() + + + +```go +type ServiceAccountTokenRevokeResponse struct { + Revoked bool `json:"revoked"` + JSON struct { + Revoked respjson.Field + ExtraFields map[string]respjson.Field + // contains filtered or unexported fields + } `json:"-"` +} +``` + + +### func \(ServiceAccountTokenRevokeResponse\) [RawJSON]() + +```go +func (r ServiceAccountTokenRevokeResponse) RawJSON() string +``` + + + + +### func \(\*ServiceAccountTokenRevokeResponse\) [UnmarshalJSON]() + +```go +func (r *ServiceAccountTokenRevokeResponse) UnmarshalJSON(data []byte) error +``` + + + + +## type [ServiceAccountTokenService]() + +ServiceAccountTokenService issues admin SATs and revokes Forward SAT bindings. These methods require a per\-call SA Key, rather than the client's PAT or SAT. + +```go +type ServiceAccountTokenService struct { + Options []option.RequestOption +} +``` + + +### func [NewServiceAccountTokenService]() + +```go +func NewServiceAccountTokenService(opts ...option.RequestOption) ServiceAccountTokenService +``` + + + + +### func \(\*ServiceAccountTokenService\) [New]() + +```go +func (r *ServiceAccountTokenService) New(ctx context.Context, params ServiceAccountTokenNewParams, opts ...option.RequestOption) (res *ServiceAccountToken, err error) +``` + +New issues an admin SAT. Automatic retries are disabled to avoid duplicate issuance. + + +### func \(\*ServiceAccountTokenService\) [Revoke]() + +```go +func (r *ServiceAccountTokenService) Revoke(ctx context.Context, authTokenID string, params ServiceAccountTokenRevokeParams, opts ...option.RequestOption) (res *ServiceAccountTokenRevokeResponse, err error) +``` + +Revoke revokes a Forward SAT binding belonging to the same Service Account. Use the issuance response's CredentialID and ExpiresAt to disambiguate the target. + ## type [Session]() diff --git a/examples/auth/forward-tokens/main.go b/examples/auth/forward-tokens/main.go new file mode 100644 index 0000000..9c104cf --- /dev/null +++ b/examples/auth/forward-tokens/main.go @@ -0,0 +1,81 @@ +// Issue a Forward admin/Identity SAT, make one read-only business call, then revoke it. +// Set QODER_SA_KEY and QODER_SA_REGION=cn|global. Set QODER_IDENTITY_ID for Identity mode. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "time" + + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/examples/internal/live" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward" +) + +func main() { + flag.Parse() + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "Forward token example failed: "+live.Safe(err)) + os.Exit(1) + } +} + +func run() (err error) { + key := os.Getenv("QODER_SA_KEY") + if key == "" { + return fmt.Errorf("configure QODER_SA_KEY") + } + region := os.Getenv("QODER_SA_REGION") + if region == "" { + region = "cn" + } + if region != "cn" && region != "global" { + return fmt.Errorf("QODER_SA_REGION must be cn or global") + } + host := "https://api.qoder.com" + if region == "cn" { + host = "https://api.qoder.com.cn" + } + baseURL := host + "/api/v1/forward" + issuer := forward.NewClient(option.WithBaseURL(baseURL), option.WithAuthMode("sa")) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + identityID := os.Getenv("QODER_IDENTITY_ID") + params := forward.ServiceAccountTokenNewParams{SAKey: key, TTLSeconds: forward.Int(600), Metadata: map[string]any{"purpose": "sdk example"}} + var token *forward.ServiceAccountToken + if identityID == "" { + token, err = issuer.ServiceAccountTokens.New(ctx, params) + } else { + token, err = issuer.Identities.ServiceAccountTokens.New(ctx, identityID, params) + } + if err != nil { + return err + } + defer func() { + cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _, revokeErr := issuer.ServiceAccountTokens.Revoke(cleanup, token.AuthTokenID, forward.ServiceAccountTokenRevokeParams{ + SAKey: key, TargetCredentialID: forward.String(token.CredentialID), TargetExpiresAt: param.NewOpt(token.ExpiresAt), Reason: forward.String("sdk example complete"), + }) + err = errors.Join(err, revokeErr) + }() + runtime := forward.NewClient(option.WithBaseURL(baseURL), option.WithAuthMode("sa"), option.WithAuthToken(token.AccessToken), option.WithMaxRetries(0)) + if identityID == "" { + page, callErr := runtime.Templates.List(ctx, forward.TemplateListParams{Limit: forward.Int(1)}) + if callErr != nil { + return callErr + } + fmt.Println("Forward templates:", len(page.Data)) + } else { + page, callErr := runtime.Sessions.List(ctx, forward.SessionListParams{IdentityIDs: []string{identityID}, Limit: forward.Int(1)}) + if callErr != nil { + return callErr + } + fmt.Println("Identity sessions:", len(page.Data)) + } + return nil +} diff --git a/forward/client.go b/forward/client.go index dfa5f89..c8ee011 100644 --- a/forward/client.go +++ b/forward/client.go @@ -12,22 +12,23 @@ const DefaultBaseURL = "https://api.qoder.com/api/v1/forward/" // Client exposes Forward resources. Configure options before sharing a client. type Client struct { - Options []option.RequestOption - Templates TemplateService - Identities IdentityService - Sessions SessionService - Schedules ScheduleService - ScheduleRuns ScheduleRunService - Batches BatchService - Channels ChannelService - ChannelPairings ChannelPairingService - Environments EnvironmentService - Files FileService - Skills SkillService - Vaults VaultService - MemoryStores MemoryStoreService - Usage UsageService - Models ModelService + Options []option.RequestOption + Templates TemplateService + Identities IdentityService + Sessions SessionService + Schedules ScheduleService + ScheduleRuns ScheduleRunService + Batches BatchService + Channels ChannelService + ChannelPairings ChannelPairingService + Environments EnvironmentService + Files FileService + Skills SkillService + Vaults VaultService + MemoryStores MemoryStoreService + Usage UsageService + Models ModelService + ServiceAccountTokens ServiceAccountTokenService } // NewClient uses common authentication defaults and QODER_FORWARD_BASE_URL. Explicit options win. @@ -42,20 +43,21 @@ func NewClient(opts ...option.RequestOption) Client { defaults = append(defaults, convention.EnvironmentAuthentication()) opts = slices.Concat(defaults, opts) return Client{Options: opts, - Templates: NewTemplateService(opts...), - Identities: NewIdentityService(opts...), - Sessions: NewSessionService(opts...), - Schedules: NewScheduleService(opts...), - ScheduleRuns: NewScheduleRunService(opts...), - Batches: NewBatchService(opts...), - Channels: NewChannelService(opts...), - ChannelPairings: NewChannelPairingService(opts...), - Environments: NewEnvironmentService(opts...), - Files: NewFileService(opts...), - Skills: NewSkillService(opts...), - Vaults: NewVaultService(opts...), - MemoryStores: NewMemoryStoreService(opts...), - Usage: NewUsageService(opts...), - Models: NewModelService(opts...), + Templates: NewTemplateService(opts...), + Identities: NewIdentityService(opts...), + Sessions: NewSessionService(opts...), + Schedules: NewScheduleService(opts...), + ScheduleRuns: NewScheduleRunService(opts...), + Batches: NewBatchService(opts...), + Channels: NewChannelService(opts...), + ChannelPairings: NewChannelPairingService(opts...), + Environments: NewEnvironmentService(opts...), + Files: NewFileService(opts...), + Skills: NewSkillService(opts...), + Vaults: NewVaultService(opts...), + MemoryStores: NewMemoryStoreService(opts...), + Usage: NewUsageService(opts...), + Models: NewModelService(opts...), + ServiceAccountTokens: NewServiceAccountTokenService(opts...), } } diff --git a/forward/client_test.go b/forward/client_test.go index 8b2d629..4e175a1 100644 --- a/forward/client_test.go +++ b/forward/client_test.go @@ -34,8 +34,8 @@ func TestForwardAPIInventory(t *testing.T) { } } walk(root) - if len(actual) != 113 { - t.Fatalf("HTTP methods: %d want 113", len(actual)) + if len(actual) != 116 { + t.Fatalf("HTTP methods: %d want 116", len(actual)) } baseline := map[string]bool{} for _, c := range operations(t) { diff --git a/forward/identity.go b/forward/identity.go index 288bc04..6c0997a 100644 --- a/forward/identity.go +++ b/forward/identity.go @@ -19,13 +19,14 @@ import ( // IdentityService provides Forward Identity operations. type IdentityService struct { - Options []option.RequestOption - Configs IdentityConfigService - MemoryStores IdentityMemoryStoreService + Options []option.RequestOption + Configs IdentityConfigService + MemoryStores IdentityMemoryStoreService + ServiceAccountTokens IdentityServiceAccountTokenService } func NewIdentityService(opts ...option.RequestOption) IdentityService { - return IdentityService{Options: slices.Clone(opts), Configs: NewIdentityConfigService(opts...), MemoryStores: NewIdentityMemoryStoreService(opts...)} + return IdentityService{Options: slices.Clone(opts), Configs: NewIdentityConfigService(opts...), MemoryStores: NewIdentityMemoryStoreService(opts...), ServiceAccountTokens: NewIdentityServiceAccountTokenService(opts...)} } // List Identities diff --git a/forward/identityserviceaccounttoken.go b/forward/identityserviceaccounttoken.go new file mode 100644 index 0000000..6530a1d --- /dev/null +++ b/forward/identityserviceaccounttoken.go @@ -0,0 +1,35 @@ +package forward + +import ( + "context" + "fmt" + "net/http" + "net/url" + "slices" + + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option" +) + +// IdentityServiceAccountTokenService issues SATs bound to a Forward Identity. +type IdentityServiceAccountTokenService struct { + Options []option.RequestOption +} + +func NewIdentityServiceAccountTokenService(opts ...option.RequestOption) IdentityServiceAccountTokenService { + return IdentityServiceAccountTokenService{Options: slices.Clone(opts)} +} + +// New issues an Identity SAT using the explicit SA Key. Automatic retries are disabled. +func (r *IdentityServiceAccountTokenService) New(ctx context.Context, identityID string, params ServiceAccountTokenNewParams, opts ...option.RequestOption) (res *ServiceAccountToken, err error) { + if identityID == "" { + return nil, fmt.Errorf("missing required identity_id parameter") + } + opts, err = serviceAccountTokenOptions(params.SAKey, slices.Concat(r.Options, opts)) + if err != nil { + return nil, err + } + path := fmt.Sprintf("identities/%s/service_account_tokens", url.PathEscape(identityID)) + err = convention.ExecuteNewRequest(ctx, http.MethodPost, path, params, &res, opts...) + return res, err +} diff --git a/forward/serviceaccounttoken.go b/forward/serviceaccounttoken.go new file mode 100644 index 0000000..f57db08 --- /dev/null +++ b/forward/serviceaccounttoken.go @@ -0,0 +1,153 @@ +package forward + +import ( + "context" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/apijson" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/respjson" +) + +// ServiceAccountTokenService issues admin SATs and revokes Forward SAT bindings. +// These methods require a per-call SA Key, rather than the client's PAT or SAT. +type ServiceAccountTokenService struct { + Options []option.RequestOption +} + +func NewServiceAccountTokenService(opts ...option.RequestOption) ServiceAccountTokenService { + return ServiceAccountTokenService{Options: slices.Clone(opts)} +} + +// New issues an admin SAT. Automatic retries are disabled to avoid duplicate issuance. +func (r *ServiceAccountTokenService) New(ctx context.Context, params ServiceAccountTokenNewParams, opts ...option.RequestOption) (res *ServiceAccountToken, err error) { + opts, err = serviceAccountTokenOptions(params.SAKey, slices.Concat(r.Options, opts)) + if err != nil { + return nil, err + } + err = convention.ExecuteNewRequest(ctx, http.MethodPost, "service_account_tokens", params, &res, opts...) + return res, err +} + +// Revoke revokes a Forward SAT binding belonging to the same Service Account. +// Use the issuance response's CredentialID and ExpiresAt to disambiguate the target. +func (r *ServiceAccountTokenService) Revoke(ctx context.Context, authTokenID string, params ServiceAccountTokenRevokeParams, opts ...option.RequestOption) (res *ServiceAccountTokenRevokeResponse, err error) { + if authTokenID == "" { + return nil, fmt.Errorf("missing required auth_token_id parameter") + } + opts, err = serviceAccountTokenOptions(params.SAKey, slices.Concat(r.Options, opts)) + if err != nil { + return nil, err + } + path := fmt.Sprintf("service_account_tokens/%s/revoke", url.PathEscape(authTokenID)) + err = convention.ExecuteNewRequest(ctx, http.MethodPost, path, params, &res, opts...) + return res, err +} + +func serviceAccountTokenOptions(saKey string, opts []option.RequestOption) ([]option.RequestOption, error) { + if saKey == "" || strings.ContainsFunc(saKey, func(r rune) bool { return r <= 0x20 || r == 0x7f }) { + return nil, fmt.Errorf("sa_key must be non-empty and contain no whitespace or control characters") + } + // Apply last so client/request auth overrides cannot replace the explicit Key. + return append(opts, option.WithHeader("Authorization", "Bearer "+saKey), option.WithHeader("Content-Type", "application/json"), option.WithMaxRetries(0)), nil +} + +type ServiceAccountTokenNewParams struct { + // SA Key used only in this request's Authorization header; never serialized as JSON. + SAKey string `json:"-"` + // Lifetime in seconds, 1-43200. Omit to use the issuer's default. + TTLSeconds param.Opt[int64] `json:"ttl_seconds,omitzero"` + // Business audit metadata; do not include credentials or other secrets. + Metadata map[string]any `json:"metadata,omitzero"` + paramObj +} + +func (r ServiceAccountTokenNewParams) MarshalJSON() ([]byte, error) { + type shadow ServiceAccountTokenNewParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *ServiceAccountTokenNewParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} +func (r ServiceAccountTokenNewParams) String() string { + return "ServiceAccountTokenNewParams{SAKey: [redacted]}" +} +func (r ServiceAccountTokenNewParams) GoString() string { return r.String() } + +type ServiceAccountTokenRevokeParams struct { + SAKey string `json:"-"` + TargetCredentialID param.Opt[string] `json:"target_credential_id,omitzero"` + TargetExpiresAt param.Opt[time.Time] `json:"target_expires_at,omitzero" format:"date-time"` + Reason param.Opt[string] `json:"reason,omitzero"` + paramObj +} + +func (r ServiceAccountTokenRevokeParams) MarshalJSON() ([]byte, error) { + type shadow ServiceAccountTokenRevokeParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *ServiceAccountTokenRevokeParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} +func (r ServiceAccountTokenRevokeParams) String() string { + return "ServiceAccountTokenRevokeParams{SAKey: [redacted]}" +} +func (r ServiceAccountTokenRevokeParams) GoString() string { return r.String() } + +// ServiceAccountToken contains a newly issued Forward SAT. Treat AccessToken and RawJSON as secrets. +// Use WithAuthMode("sa") and WithAuthToken(AccessToken) for business calls; renewal is caller-owned. +type ServiceAccountToken struct { + Type string `json:"type"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + ExpiresAt time.Time `json:"expires_at" format:"date-time"` + AuthTokenID string `json:"auth_token_id"` + ServiceAccountID string `json:"service_account_id"` + CredentialID string `json:"credential_id"` + // admin or identity. IdentityID is absent for admin tokens. + SubjectType string `json:"subject_type"` + IdentityID string `json:"identity_id"` + JSON struct { + Type respjson.Field + AccessToken respjson.Field + TokenType respjson.Field + ExpiresIn respjson.Field + ExpiresAt respjson.Field + AuthTokenID respjson.Field + ServiceAccountID respjson.Field + CredentialID respjson.Field + SubjectType respjson.Field + IdentityID respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +func (r ServiceAccountToken) RawJSON() string { return r.JSON.raw } +func (r *ServiceAccountToken) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +func (r ServiceAccountToken) String() string { + return fmt.Sprintf("ServiceAccountToken{AuthTokenID: %q, SubjectType: %q, AccessToken: [redacted]}", r.AuthTokenID, r.SubjectType) +} +func (r ServiceAccountToken) GoString() string { return r.String() } + +type ServiceAccountTokenRevokeResponse struct { + Revoked bool `json:"revoked"` + JSON struct { + Revoked respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +func (r ServiceAccountTokenRevokeResponse) RawJSON() string { return r.JSON.raw } +func (r *ServiceAccountTokenRevokeResponse) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} diff --git a/forward/serviceaccounttoken_test.go b/forward/serviceaccounttoken_test.go new file mode 100644 index 0000000..ce584d2 --- /dev/null +++ b/forward/serviceaccounttoken_test.go @@ -0,0 +1,204 @@ +package forward_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward" +) + +func TestServiceAccountTokenAPIContracts(t *testing.T) { + for _, id := range []string{"createServiceAccountToken", "createIdentityServiceAccountToken", "revokeServiceAccountToken"} { + t.Run(id, func(t *testing.T) { + for _, status := range []int{200, 400} { + client := contractClient(t, id, status) + var res any + var err error + switch id { + case "createServiceAccountToken": + res, err = client.ServiceAccountTokens.New(context.Background(), contractParams[forward.ServiceAccountTokenNewParams](t, id)) + case "createIdentityServiceAccountToken": + res, err = client.Identities.ServiceAccountTokens.New(context.Background(), pathSegment, contractParams[forward.ServiceAccountTokenNewParams](t, id)) + case "revokeServiceAccountToken": + res, err = client.ServiceAccountTokens.Revoke(context.Background(), pathSegment, contractParams[forward.ServiceAccountTokenRevokeParams](t, id)) + } + checkError(t, status, err) + if status == 200 { + checkDecoded(t, res) + } + } + }) + } +} + +type tokenManagementCredential struct{ calls int } + +func (c *tokenManagementCredential) Token(context.Context) (string, error) { + c.calls++ + return "business-token", nil +} + +func TestServiceAccountTokenWithoutBusinessCredentials(t *testing.T) { + t.Setenv("QODER_PAT", "") + t.Setenv("QODER_ACCESS_TOKEN", "") + client := forward.NewClient(option.WithAuthMode("sa"), option.WithBaseURL("https://api.test/api/v1/forward"), option.WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Header.Get("Authorization") != "Bearer explicit-sa-key" { + t.Fatal("incorrect Key authentication") + } + return reply(req, 201, string(contract(t, "createServiceAccountToken").Response)), nil + })})) + if _, err := client.ServiceAccountTokens.New(context.Background(), forward.ServiceAccountTokenNewParams{SAKey: "explicit-sa-key"}); err != nil { + t.Fatal(err) + } +} + +func TestServiceAccountTokenKeyIsRequestScoped(t *testing.T) { + t.Setenv("QODER_PAT", "ambient-pat") + t.Setenv("QODER_ACCESS_TOKEN", "ambient-sat") + for _, dynamic := range []bool{false, true} { + credential := &tokenManagementCredential{} + var requests []*http.Request + var bodies []map[string]any + opts := []option.RequestOption{option.WithPAT("business-token")} + if dynamic { + opts = []option.RequestOption{option.WithCredential(credential)} + } + opts = append(opts, option.WithBaseURL("https://api.test/api/v1/forward"), option.WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requests = append(requests, req) + if req.Method == "GET" { + return reply(req, 200, `{"data":[]}`), nil + } + body := map[string]any{} + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatal(err) + } + bodies = append(bodies, body) + if strings.HasSuffix(req.URL.Path, "/revoke") { + return reply(req, 200, `{"revoked":true}`), nil + } + return reply(req, 201, string(contract(t, "createServiceAccountToken").Response)), nil + })})) + client := forward.NewClient(opts...) + token, err := client.ServiceAccountTokens.New(context.Background(), forward.ServiceAccountTokenNewParams{SAKey: "explicit-sa-key"}, option.WithHeader("authorization", "Bearer wrong")) + if err != nil { + t.Fatal(err) + } + if credential.calls != 0 { + t.Fatal("token management called the business credential") + } + if _, err = client.Identities.ServiceAccountTokens.New(context.Background(), "idn_one", forward.ServiceAccountTokenNewParams{SAKey: "explicit-sa-key"}); err != nil { + t.Fatal(err) + } + if _, err = client.ServiceAccountTokens.Revoke(context.Background(), token.AuthTokenID, forward.ServiceAccountTokenRevokeParams{SAKey: "explicit-sa-key"}); err != nil { + t.Fatal(err) + } + if _, err = client.Models.List(context.Background()); err != nil { + t.Fatal(err) + } + for i, req := range requests { + want := "Bearer explicit-sa-key" + if i == 3 { + want = "Bearer business-token" + } + if req.Header.Get("Authorization") != want { + t.Fatal("incorrect request authentication") + } + } + for _, body := range bodies { + if len(body) != 0 { + t.Fatalf("omitted parameters must produce {}: %v", body) + } + } + if dynamic && credential.calls != 1 { + t.Fatal("business credential was not retained") + } + if token.AccessToken != "sat-contract-secret" || token.ExpiresAt.IsZero() || token.JSON.IdentityID.Valid() { + t.Fatal("admin token fields not decoded") + } + if strings.Contains(fmt.Sprintf("%+v %#v", token, token), token.AccessToken) { + t.Fatal("formatting exposed SAT") + } + } +} + +func TestServiceAccountTokenNeverRetries(t *testing.T) { + for _, method := range []string{"admin", "identity", "revoke"} { + for _, status := range []int{429, 503, 0} { + t.Run(fmt.Sprintf("%s/%d", method, status), func(t *testing.T) { + calls := 0 + cause := errors.New("connection interrupted") + client := testClient(func(req *http.Request) (*http.Response, error) { + calls++ + if status == 0 { + return nil, cause + } + response := reply(req, status, `{"error":{"type":"api_error","message":"issuance uncertain"}}`) + response.Header.Set("x-should-retry", "true") + response.Header.Set("retry-after-ms", "1") + return response, nil + }, option.WithMaxRetries(3)) + opts := []option.RequestOption{option.WithMaxRetries(4), option.WithHeader("Idempotency-Key", "unsupported-key")} + var err error + switch method { + case "admin": + _, err = client.ServiceAccountTokens.New(context.Background(), forward.ServiceAccountTokenNewParams{SAKey: "sa-key"}, opts...) + case "identity": + _, err = client.Identities.ServiceAccountTokens.New(context.Background(), "idn_one", forward.ServiceAccountTokenNewParams{SAKey: "sa-key"}, opts...) + case "revoke": + _, err = client.ServiceAccountTokens.Revoke(context.Background(), "auth_one", forward.ServiceAccountTokenRevokeParams{SAKey: "sa-key"}, opts...) + } + if err == nil || calls != 1 { + t.Fatalf("err=%v calls=%d", err, calls) + } + if status == 0 && !errors.Is(err, cause) { + t.Fatal("transport cause lost") + } + }) + } + } +} + +func TestServiceAccountTokenMissingKeyFailsBeforeTransport(t *testing.T) { + client := testClient(func(*http.Request) (*http.Response, error) { t.Fatal("invalid Key reached transport"); return nil, nil }) + for _, key := range []string{"", " ", "key\nInjected:value", "key\t", "key\x00"} { + if _, err := client.ServiceAccountTokens.New(context.Background(), forward.ServiceAccountTokenNewParams{SAKey: key}); err == nil { + t.Fatal("invalid Key accepted") + } + if _, err := client.Identities.ServiceAccountTokens.New(context.Background(), "idn_one", forward.ServiceAccountTokenNewParams{SAKey: key}); err == nil { + t.Fatal("invalid Key accepted") + } + if _, err := client.ServiceAccountTokens.Revoke(context.Background(), "auth_one", forward.ServiceAccountTokenRevokeParams{SAKey: key}); err == nil { + t.Fatal("invalid Key accepted") + } + } +} + +func TestServiceAccountTokenJSONDoesNotContainKey(t *testing.T) { + params := forward.ServiceAccountTokenNewParams{SAKey: "secret-sa-key", TTLSeconds: forward.Int(43200), Metadata: map[string]any{"purpose": "audit"}} + b, err := json.Marshal(params) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err = json.NewDecoder(strings.NewReader(string(b))).Decode(&body); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(body, map[string]any{"ttl_seconds": float64(43200), "metadata": map[string]any{"purpose": "audit"}}) { + t.Fatal("incorrect issuance JSON") + } + if strings.Contains(fmt.Sprintf("%+v %#v", params, params), params.SAKey) { + t.Fatal("formatting exposed SA Key") + } + // The request body must remain {} when only authentication is configured. + b, err = json.Marshal(forward.ServiceAccountTokenRevokeParams{SAKey: "secret-sa-key"}) + if err != nil || string(b) != "{}" { + t.Fatalf("body=%s err=%v", b, err) + } +} diff --git a/forward/test_helpers_test.go b/forward/test_helpers_test.go index 19d5925..21359e2 100644 --- a/forward/test_helpers_test.go +++ b/forward/test_helpers_test.go @@ -36,6 +36,7 @@ func testClient(fn roundTripFunc, opts ...option.RequestOption) forward.Client { type operationCase struct { OperationID string `json:"operation_id"` + Auth string `json:"auth"` Method string `json:"http_method"` Path string `json:"path"` Parameters []parameterCase `json:"parameters"` @@ -85,8 +86,8 @@ func operations(t *testing.T) []operationCase { Cases []operationCase `json:"cases"` } readJSON(t, "testdata/api-operation-cases.json", &fixture) - if fixture.Count != 113 || len(fixture.Cases) != 113 { - t.Fatal("Forward API scope must contain 113 operations") + if fixture.Count != 116 || len(fixture.Cases) != 116 { + t.Fatal("Forward API scope must contain 116 operations") } return fixture.Cases } @@ -173,6 +174,9 @@ func contractParams[T any](t *testing.T, id string) T { } } v := reflect.ValueOf(¶ms).Elem() + if c.Auth == "sa_key" { + v.FieldByName("SAKey").SetString("contract-sa-key") + } for _, p := range c.Parameters { if p.Location != "path" { setField(t, v, p.Location, p.Name, parameterValue(p)) @@ -233,7 +237,11 @@ func contractClient(t *testing.T, id string, status int) forward.Client { if r.URL.EscapedPath() != "/api/v1/forward"+path { t.Errorf("path: %s want %s", r.URL.EscapedPath(), path) } - if r.Header.Get("Authorization") != "Bearer secret-pat" { + wantAuth := "Bearer secret-pat" + if c.Auth == "sa_key" { + wantAuth = "Bearer contract-sa-key" + } + if r.Header.Get("Authorization") != wantAuth { t.Error("missing bearer authentication") } for _, p := range c.Parameters { diff --git a/forward/testdata/api-contracts.json b/forward/testdata/api-contracts.json index 83c9b7d..9bf5338 100644 --- a/forward/testdata/api-contracts.json +++ b/forward/testdata/api-contracts.json @@ -3250,5 +3250,64 @@ "download": false, "raw": false, "empty": false + }, + { + "operation_id": "createServiceAccountToken", + "entry": "ServiceAccountTokens", + "response": { + "type": "service_account_token", + "access_token": "sat-contract-secret", + "token_type": "Bearer", + "expires_in": 3600, + "expires_at": "2026-10-13T12:00:00Z", + "auth_token_id": "auth_contract", + "service_account_id": "sa_contract", + "credential_id": "sak_contract", + "subject_type": "admin" + }, + "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create", + "service": "ServiceAccountTokenService", + "name": "New", + "stream": false, + "download": false, + "raw": false, + "empty": false + }, + { + "operation_id": "createIdentityServiceAccountToken", + "entry": "Identities.ServiceAccountTokens", + "response": { + "type": "service_account_token", + "access_token": "sat-contract-secret", + "token_type": "Bearer", + "expires_in": 3600, + "expires_at": "2026-10-13T12:00:00Z", + "auth_token_id": "auth_contract", + "service_account_id": "sa_contract", + "credential_id": "sak_contract", + "subject_type": "identity", + "identity_id": "idn_contract" + }, + "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create-identity", + "service": "IdentityServiceAccountTokenService", + "name": "New", + "stream": false, + "download": false, + "raw": false, + "empty": false + }, + { + "operation_id": "revokeServiceAccountToken", + "entry": "ServiceAccountTokens", + "response": { + "revoked": true + }, + "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/revoke", + "service": "ServiceAccountTokenService", + "name": "Revoke", + "stream": false, + "download": false, + "raw": false, + "empty": false } ] diff --git a/forward/testdata/api-operation-cases.json b/forward/testdata/api-operation-cases.json index 9358fd7..5be5847 100644 --- a/forward/testdata/api-operation-cases.json +++ b/forward/testdata/api-operation-cases.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "operation_count": 113, + "operation_count": 116, "cases": [ { "operation_id": "addSessionResource", @@ -4924,6 +4924,70 @@ ], "request_body": null, "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)" + }, + { + "operation_id": "createServiceAccountToken", + "http_method": "POST", + "path": "/service_account_tokens", + "auth": "sa_key", + "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create", + "parameters": [], + "request_body": { + "kind": "json", + "value": { + "ttl_seconds": 3600, + "metadata": { + "purpose": "admin automation" + } + } + } + }, + { + "operation_id": "createIdentityServiceAccountToken", + "http_method": "POST", + "path": "/identities/{identity_id}/service_account_tokens", + "auth": "sa_key", + "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/create-identity", + "parameters": [ + { + "wire_name": "identity_id", + "location": "path", + "required": true, + "value": "idn_contract" + } + ], + "request_body": { + "kind": "json", + "value": { + "ttl_seconds": 3600, + "metadata": { + "purpose": "identity runtime" + } + } + } + }, + { + "operation_id": "revokeServiceAccountToken", + "http_method": "POST", + "path": "/service_account_tokens/{auth_token_id}/revoke", + "auth": "sa_key", + "source": "https://docs.qoder.com/cloud-agents/api/forward/service-account-tokens/revoke", + "parameters": [ + { + "wire_name": "auth_token_id", + "location": "path", + "required": true, + "value": "auth_contract" + } + ], + "request_body": { + "kind": "json", + "value": { + "target_credential_id": "sak_contract", + "target_expires_at": "2026-10-13T12:00:00Z", + "reason": "session ended" + } + } } ] } diff --git a/integration/sa_tokens_live_test.go b/integration/sa_tokens_live_test.go new file mode 100644 index 0000000..5324a1f --- /dev/null +++ b/integration/sa_tokens_live_test.go @@ -0,0 +1,111 @@ +//go:build live + +package integration_test + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/apierror" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/internal/testsupport" +) + +func saTokenDenied(t *testing.T, err error, statuses ...int) { + t.Helper() + var api *apierror.Error + if errors.As(err, &api) { + for _, status := range statuses { + if api.StatusCode == status { + return + } + } + } + t.Fatalf("expected access refusal: %s", testsupport.SafeError(err)) +} + +func saTokenMetadata(t *testing.T, token *forward.ServiceAccountToken, subject, identityID string) { + t.Helper() + if token == nil || token.AccessToken == "" || token.Type != "service_account_token" || token.TokenType != "Bearer" || + token.AuthTokenID == "" || token.CredentialID == "" || token.ServiceAccountID == "" || + token.SubjectType != subject || token.IdentityID != identityID || token.ExpiresIn <= 0 || token.ExpiresIn > 600 || + !token.ExpiresAt.After(time.Now()) { + t.Fatal("issued SAT metadata did not match the requested subject and lifetime") + } + t.Logf("issued subject=%s auth_token_id=%s credential_id=%s identity_id=%s expires_at=%s", subject, token.AuthTokenID, token.CredentialID, token.IdentityID, token.ExpiresAt.UTC().Format(time.RFC3339)) +} + +func TestForwardServiceAccountTokensLive(t *testing.T) { + saRequireConfiguration(t, false) + if os.Getenv("QODER_SA_LIVE_ALLOW_WRITE") != "true" { + t.Fatal("QODER_SA_LIVE_ALLOW_WRITE=true is required") + } + for _, region := range []string{"cn", "global"} { + t.Run(region, func(t *testing.T) { + variable, host := "QODER_SA_GLOBAL_KEY", "https://api.qoder.com" + if region == "cn" { + variable, host = "QODER_SA_CN_KEY", "https://api.qoder.com.cn" + } + key := os.Getenv(variable) + opts := []option.RequestOption{option.WithBaseURL(host + "/api/v1/forward/"), option.WithAuthMode("sa"), option.WithMaxRetries(0), option.WithRequestTimeout(30 * time.Second), testsupport.RequestLog(t)} + issuer := forward.NewClient(opts...) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + params := forward.ServiceAccountTokenNewParams{SAKey: key, TTLSeconds: forward.Int(600), Metadata: map[string]any{"purpose": "sdk verification", "sdk": "go"}} + adminToken, err := issuer.ServiceAccountTokens.New(ctx, params) + saCheck(t, err) + admin := forward.NewClient(append(opts, option.WithAuthToken(adminToken.AccessToken))...) + revoke := func(ctx context.Context, token *forward.ServiceAccountToken) error { + result, err := issuer.ServiceAccountTokens.Revoke(ctx, token.AuthTokenID, forward.ServiceAccountTokenRevokeParams{SAKey: key, TargetCredentialID: forward.String(token.CredentialID), TargetExpiresAt: param.NewOpt(token.ExpiresAt), Reason: forward.String("sdk verification complete")}) + if err == nil && (result == nil || !result.Revoked) { + return errors.New("revoke did not acknowledge success") + } + return err + } + saCleanup(t, "admin SAT "+adminToken.AuthTokenID, func(ctx context.Context) error { + if err := revoke(ctx, adminToken); err != nil { + return err + } + _, err := admin.Templates.List(ctx, forward.TemplateListParams{Limit: forward.Int(1)}) + saTokenDenied(t, err, 401, 403) + return nil + }) + saTokenMetadata(t, adminToken, "admin", "") + _, err = admin.Templates.List(ctx, forward.TemplateListParams{Limit: forward.Int(1)}) + saCheck(t, err) + identities := make([]string, 0, 2) + for i := 0; i < 2; i++ { + identity, err := admin.Identities.New(ctx, forward.IdentityNewParams{ExternalID: "sdk-go-sat-" + testsupport.Marker(t)}) + saCheck(t, err) + id := identity.ID + saCleanup(t, "identity "+id, func(ctx context.Context) error { _, err := admin.Identities.Delete(ctx, id); return err }) + if id == "" { + t.Fatal("created Identity must have an ID") + } + identities = append(identities, id) + } + identityToken, err := issuer.Identities.ServiceAccountTokens.New(ctx, identities[0], params) + saCheck(t, err) + runtime := forward.NewClient(append(opts, option.WithAuthToken(identityToken.AccessToken))...) + saCleanup(t, "identity SAT "+identityToken.AuthTokenID, func(ctx context.Context) error { + if err := revoke(ctx, identityToken); err != nil { + return err + } + _, err := runtime.Identities.Configs.List(ctx, identities[0], forward.IdentityConfigListParams{Limit: forward.Int(1)}) + saTokenDenied(t, err, 401, 403) + return nil + }) + saTokenMetadata(t, identityToken, "identity", identities[0]) + _, err = runtime.Identities.Configs.List(ctx, identities[0], forward.IdentityConfigListParams{Limit: forward.Int(1)}) + saCheck(t, err) + _, err = runtime.Identities.Configs.List(ctx, identities[1], forward.IdentityConfigListParams{Limit: forward.Int(1)}) + saTokenDenied(t, err, 403) + t.Log("verified admin business access, Identity business access and cross-Identity refusal") + }) + } +} diff --git a/internal/testsupport/contracts.go b/internal/testsupport/contracts.go index c23244c..94a0c80 100644 --- a/internal/testsupport/contracts.go +++ b/internal/testsupport/contracts.go @@ -158,6 +158,9 @@ func call(t *testing.T, method reflect.Value, ctx context.Context, emptyPath int } } for j := 0; j < typ.NumField(); j++ { + if typ.Field(j).Name == "SAKey" && v.Field(j).Kind() == reflect.String { + v.Field(j).SetString("contract-sa-key") + } if typ.Field(j).Tag.Get("path") != "" && v.Field(j).Kind() == reflect.String && emptyPath != 1000*i+j { v.Field(j).SetString("segment /?%#") }