Skip to content
Draft
137 changes: 137 additions & 0 deletions POC_README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# organization2 PoC (`mongodbatlas_organization2`)

Mock-backed Terraform Plugin Framework resource that demonstrates **ModifyPlan**-driven client secret rotation with an optional `client_secret_rotation` block. It is an **anti-pattern reference** for embedded org credentials; production Atlas org design should use child `mongodbatlas_service_account` and `mongodbatlas_service_account_secret` resources instead.

## Purpose

- Show that computed `next_renewal` / `expires_at` alone do not schedule rotation; the rotation **block** opts into ModifyPlan.
- Show plan shape when rotation is due or forced: known `secret_version` increment, known `old_secret_id`, unknown secret fields.
- Show practitioner-forced rotation via a higher `secret_version` in config before the calendar deadline.

## Example

```hcl
resource "mongodbatlas_organization2" "demo" {
name = "demo-org"

client_secret_rotation = {
interval = "240h"
# secret_version = 2 # optional: force rotation before next_renewal
}
}
```

Without the `client_secret_rotation` block, the resource still creates mock `client_id` / `client_secret` on create, but no scheduled rotation runs.

## Production pattern (do not copy this resource)

- Keep org identity and settings on `mongodbatlas_organization`.
- Create credentials with `mongodbatlas_service_account` and `mongodbatlas_service_account_secret`.
- Use two secret resources for overlap, or `terraform apply -replace` / optional `force_renew` on the secret resource.
- Use `time_rotating` + `replace_triggered_by` for calendar-driven rotation without provider clocks.

## Limitations

- Mock store persisted to a local JSON file (default `$TMPDIR/mongodbatlas-organization2-poc-store.json`; override with `MONGODB_ATLAS_ORGANIZATION2_POC_STORE`); no Atlas API calls.
- Always registered on the muxed TPF provider (experimental).
- `old_secret_id` in state is teaching-only overlap visibility; production uses two secret resources.
- Removing `client_secret_rotation` from config stops future ModifyPlan rotation; `old_secret_id` may remain stale in state.
- No registry docs, examples, or CHANGELOG entry for this PoC.

## Mock store file

Each provider process loads the mock store from disk on first access. Set `MONGODB_ATLAS_ORGANIZATION2_POC_STORE` to pin a path when debugging (for example next to your Terraform working directory).

```sh
export MONGODB_ATLAS_ORGANIZATION2_POC_STORE=/tmp/mongodbatlas-organization2-demo.json
terraform apply
terraform apply # Read finds the prior create; plan stays empty when not due
```

## Tests

Unit tests (no Atlas credentials):

```sh
cd code/provider
go test ./internal/service/organization2/ -run 'TestRotationDue|TestModifyPlan' -v
```

Acceptance tests (mock backend; no Atlas env vars required):

```sh
cd code/provider
TF_ACC=1 go test ./internal/service/organization2/ -run TestAccOrganization2 -v
```

Acceptance coverage:

- `TestAccOrganization2_noRotationBlock` — create with `name` only; no rotation attributes in state.
- `TestAccOrganization2_withRotationBlock` — `interval = "2s"`; sleep past due; `secret_version` increments and `old_secret_id` matches prior `current_secret_id`.
- `TestAccOrganization2_forceSecretVersion` — long `interval`; set `secret_version = 2` before due without sleep.

## Branch

Implemented on `CLOUDP-381539_org_resource_sa_rotation_support` until maintainer review.

---

# organization3 PoC (`mongodbatlas_organization3`)

Real-Atlas Terraform Plugin Framework resource that demonstrates **ModifyPlan**-driven client secret rotation with API-backed `expires_at` scheduling. Compare with the mock `mongodbatlas_organization2` PoC above.

## Schema vs organization2

- **Scheduling**: `rotate_before_expiry_hours` (default `expires_after_hours / 2`) and Atlas `current_secret.expires_at`; no `interval`, `next_renewal`, or provider-local `expires_at`.
- **Secret metadata**: Nested `current_secret` and `old_secret` objects (`secret_id`, `created_at`, `expires_at`) refreshed on read. `last_used_at` is omitted to avoid plan noise when the SA is used on refresh.
- **Practitioner inputs**: `expires_after_hours` and `rotate_before_expiry_hours` (no `secret_` prefix inside the rotation block). Maps to Atlas `secret_expires_after_hours` on create and rotation POST.
- **Deletion policy**: No delete on v1→2; from v2→3 onward DELETE `old_secret` before POST so at most two active secrets remain.
- **Destroy**: Uses embedded service account credentials from state when present (same as read/update), otherwise provider-configured credentials.

## Example

```hcl
resource "mongodbatlas_organization3" "demo" {
name = "demo-org"
org_owner_id = var.org_owner_id

client_secret_rotation = {
expires_after_hours = 720
rotate_before_expiry_hours = 360
}
}
```

## Acceptance test env

- Provider credentials with permission to create organizations (same as `mongodbatlas_organization` tests).
- `MONGODB_ATLAS_ORG_OWNER_ID`: Atlas user ID for `org_owner_id`.
- Tests call `acc.SkipUnlessHasOrgOwner()` and `acc.SkipTestForCI()`; they create and delete real organizations (cost).

## Tests

Unit tests (no Atlas credentials):

```sh
cd code/provider
go test ./internal/service/organization3/ -run 'TestRenewalDue|TestModifyPlan|TestShouldDeleteOldSecret|TestEffectiveRotateBeforeExpiryHours' -v
```

Acceptance tests (real Atlas):

```sh
cd code/provider
TF_ACC=1 go test ./internal/service/organization3/ -run TestAccOrganization3_rotationLifecycle -v
```

Acceptance coverage (`TestAccOrganization3_rotationLifecycle`):

- Create with `expires_after_hours = 8` only; `secret_version = 1`.
- Empty plan on second apply (unchanged config).
- Force rotation with `secret_version = 2`; `old_secret` matches prior `current_secret`.
- Empty plan after removing `secret_version` from config.
- Widen policy with `rotate_before_expiry_hours = 8`; `secret_version = 3` and `old_secret` matches prior current.

```sh
golangci-lint run ./internal/service/organization3/...
```
22 changes: 22 additions & 0 deletions internal/config/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package config

import "github.com/mongodb/atlas-sdk-go/auth"

// Hooks for service_account_test.go (package config_test).

func GetTokenSourceForTest(clientID, clientSecret, baseURL, terraformVersion string) (auth.TokenSource, error) {
return getTokenSource(clientID, clientSecret, baseURL, terraformVersion)
}

func ResetSATokenCacheForTest() {
saTokenCache.mu.Lock()
defer saTokenCache.mu.Unlock()
saTokenCache.closed = false
saTokenCache.entries = make(map[saCacheKey]saCacheEntry)
}

func SATokenCacheLenForTest() int {
saTokenCache.mu.Lock()
defer saTokenCache.mu.Unlock()
return len(saTokenCache.entries)
}
90 changes: 56 additions & 34 deletions internal/config/service_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,45 +15,66 @@ import (
// Renew token if it expires within 10 minutes to avoid authentication errors during Atlas API calls.
const saTokenExpiryBuffer = 10 * time.Minute

var saInfo = struct {
tokenSource auth.TokenSource
type saCacheKey struct {
clientID string
clientSecret string
baseURL string
terraformVersion string
mu sync.Mutex
closed bool
}{}
}

type saCacheEntry struct {
tokenSource auth.TokenSource
}

// saTokenCache holds OAuth token sources keyed by service account credentials.
//
// The provider process may call NewClient with different (clientID, clientSecret) pairs
// during one Terraform run—for example organization3 secret rotation (same client ID,
// new secret) or multiple SA-backed resources. A single global token source cannot
// represent more than one credential set; the previous implementation rejected credential
// changes with "service account credentials changed" and broke Read after rotation.
//
// Each key gets its own ReuseTokenSourceWithExpiry so tokens are reused per credential
// tuple without cross-talk. CloseTokenSource revokes every cached token on plugin exit.
type saTokenCacheState struct {
entries map[saCacheKey]saCacheEntry
mu sync.Mutex
closed bool
}

var saTokenCache = saTokenCacheState{
entries: make(map[saCacheKey]saCacheEntry),
}

// getTokenSource returns a cached TokenSource for the given SA credentials, creating
// and caching one on first use. baseURL is normalized before lookup.
func getTokenSource(clientID, clientSecret, baseURL, terraformVersion string) (auth.TokenSource, error) {
saInfo.mu.Lock()
defer saInfo.mu.Unlock()
saTokenCache.mu.Lock()
defer saTokenCache.mu.Unlock()

if saInfo.closed {
if saTokenCache.closed {
return nil, fmt.Errorf("service account token source already closed")
}

baseURL = NormalizeBaseURL(baseURL)
if saInfo.tokenSource != nil { // Token source in cache.
if saInfo.clientID != clientID || saInfo.clientSecret != clientSecret || saInfo.baseURL != baseURL {
return nil, fmt.Errorf("service account credentials changed")
}
return saInfo.tokenSource, nil
key := saCacheKey{
clientID: clientID,
clientSecret: clientSecret,
baseURL: NormalizeBaseURL(baseURL),
terraformVersion: terraformVersion,
}
if entry, ok := saTokenCache.entries[key]; ok {
return entry.tokenSource, nil
}

// Use a new context to avoid "context canceled" errors as the token source is reused and can outlast the callee context.
ctx := context.WithValue(context.Background(), auth.HTTPClient, NewOAuthHTTPClient(terraformVersion))
conf := GetServiceAccountConfig(clientID, clientSecret, baseURL)
conf := GetServiceAccountConfig(clientID, clientSecret, key.baseURL)
tokenSource := oauth2.ReuseTokenSourceWithExpiry(nil, conf.TokenSource(ctx), saTokenExpiryBuffer)
if _, err := tokenSource.Token(); err != nil { // Retrieve token to fail-fast if credentials are invalid.
return nil, err
}
saInfo.clientID = clientID
saInfo.clientSecret = clientSecret
saInfo.baseURL = baseURL
saInfo.terraformVersion = terraformVersion
saInfo.tokenSource = tokenSource
return saInfo.tokenSource, nil
saTokenCache.entries[key] = saCacheEntry{tokenSource: tokenSource}
return tokenSource, nil
}

func NormalizeBaseURL(baseURL string) string {
Expand All @@ -69,21 +90,22 @@ func GetServiceAccountConfig(clientID, clientSecret, baseURL string) *clientcred
return config
}

// CloseTokenSource is called just before the provider finishes, it does a best-effort try to revoke the Service Access token.
// It sets saInfo.closed = true to avoid future calls to getTokenSource, that should't happen as the provider is exiting.
// CloseTokenSource is called just before the provider finishes. It sets saTokenCache.closed
// to avoid future calls to getTokenSource, which should not happen as the provider is exiting.
// It best-effort revokes every cached OAuth token, not only the last credential set.
func CloseTokenSource() {
saInfo.mu.Lock()
defer saInfo.mu.Unlock()
if saInfo.closed {
saTokenCache.mu.Lock()
defer saTokenCache.mu.Unlock()
if saTokenCache.closed {
return
}
saInfo.closed = true
if saInfo.tokenSource == nil { // No need to do anything if SA was not initialized.
return
}
if token, err := saInfo.tokenSource.Token(); err == nil {
conf := GetServiceAccountConfig(saInfo.clientID, saInfo.clientSecret, saInfo.baseURL)
ctx := context.WithValue(context.Background(), auth.HTTPClient, NewOAuthHTTPClient(saInfo.terraformVersion))
_ = conf.RevokeToken(ctx, token) // Best-effort, no need to do anything if it fails.
saTokenCache.closed = true
for key, entry := range saTokenCache.entries {
if token, err := entry.tokenSource.Token(); err == nil {
conf := GetServiceAccountConfig(key.clientID, key.clientSecret, key.baseURL)
ctx := context.WithValue(context.Background(), auth.HTTPClient, NewOAuthHTTPClient(key.terraformVersion))
_ = conf.RevokeToken(ctx, token) // Best-effort, no need to do anything if it fails.
}
}
clear(saTokenCache.entries)
}
Loading
Loading