diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 67243417b..b04d6115b 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -133,7 +133,7 @@ code does not implement. Machine-readable: [`coverage.json`](./coverage.json). | `sagemaker` | [SageMaker](./aws/sagemaker.md) | — | — | — | 121 | | `savingsplans` | [SavingsPlans](./aws/savingsplans.md) | — | — | — | 9 | | `scheduler` | — | — | [Scheduler](./gcp/scheduler.md) | — | 8 | -| `secrets` | [SecretsManager](./aws/secretsmanager.md) | [KeyVault](./azure/keyvault.md) | [SecretManager](./gcp/secretmanager.md) | — | 7 | +| `secrets` | [SecretsManager](./aws/secretsmanager.md) | [KeyVault](./azure/keyvault.md) | [SecretManager](./gcp/secretmanager.md) | [Vault](./oci/vault.md) | 7 | | `serverless` | [Lambda](./aws/lambda.md) | [Functions](./azure/functions.md) | [CloudFunctions](./gcp/cloudfunctions.md) | — | 27 | | `servicedirectory` | — | — | [ServiceDirectory](./gcp/servicedirectory.md) | — | 15 | | `servicenetworking` | — | — | [Servicenetworking](./gcp/servicenetworking.md) | — | 3 | diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index 3cf0f52b7..513a2bce2 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -14035,7 +14035,8 @@ "providers": { "aws": "SecretsManager", "azure": "KeyVault", - "gcp": "SecretManager" + "gcp": "SecretManager", + "oci": "Vault" } }, { diff --git a/docs/coverage/oci/README.md b/docs/coverage/oci/README.md index a04d3407d..42896a793 100644 --- a/docs/coverage/oci/README.md +++ b/docs/coverage/oci/README.md @@ -8,4 +8,5 @@ Services cloudemu emulates for OCI, by native name. Back to the [cross-provider | [Identity](./identity.md) | `iam` | 40 | | [Monitoring](./monitoring.md) | `monitoring` | 12 | | [VCN](./vcn.md) | `networking` | 57 | +| [Vault](./vault.md) | `secrets` | 7 | | [Workrequest](./workrequest.md) | — (provider-native) | 4 | diff --git a/docs/coverage/oci/vault.md b/docs/coverage/oci/vault.md new file mode 100644 index 000000000..46940abeb --- /dev/null +++ b/docs/coverage/oci/vault.md @@ -0,0 +1,20 @@ + +# Vault + +OCI's `secrets` service · portable interface `driver.Secrets` · [OCI index](./README.md) + +## Operations (7) + +| Operation | Description | +| --- | --- | +| `CreateSecret` | | +| `DeleteSecret` | | +| `GetSecret` | | +| `GetSecretValue` | | +| `ListSecretVersions` | | +| `ListSecrets` | | +| `PutSecretValue` | | + +## Not in scope + +_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ diff --git a/docs/services.md b/docs/services.md index da13153e8..9bae1f538 100644 --- a/docs/services.md +++ b/docs/services.md @@ -1396,7 +1396,8 @@ a source cluster and detach on promote; clone-on-read on every path. ## 12. Secrets **Driver interface:** `services/secrets/driver/driver.go` -**AWS:** Secrets Manager | **Azure:** Key Vault | **GCP:** Secret Manager +**AWS:** Secrets Manager | **Azure:** Key Vault | **GCP:** Secret Manager | +**OCI:** Vault ### Secret Operations @@ -1417,6 +1418,134 @@ a source cluster and detach on promote; clone-on-read on every path. **Total: 7 operations** +### OCI Vault + +**Optional capability:** `server/oci/vault.Extras` — OCI keeps secret storage +and key management in one service, addresses everything by OCID, scopes it to a +compartment and a vault, and only ever *schedules* a deletion. None of that fits +the portable seven. Its value types live in `providers/oci/vault`. +**Provider:** `providers/oci/vault` | **Wire:** `server/oci/vault` + +OCI publishes Vault under two API versions and CloudEmu claims both, because one +HTTP server stands in for every OCI endpoint: `/20180608` carries KMS and secret +management, `/20190301` the secret-retrieval data plane. + +#### Vaults (`/20180608`) + +| Operation | Route | +|-----------|-------| +| `CreateVault` | `POST /vaults` | +| `ListVaults` | `GET /vaults` | +| `GetVault` | `GET /vaults/{vaultId}` | +| `UpdateVault` | `PUT /vaults/{vaultId}` | +| `ScheduleVaultDeletion` | `POST /vaults/{vaultId}/actions/scheduleDeletion` | +| `CancelVaultDeletion` | `POST /vaults/{vaultId}/actions/cancelDeletion` | +| `ChangeVaultCompartment` | `POST /vaults/{vaultId}/actions/changeCompartment` | + +#### Master encryption keys (`/20180608`) + +| Operation | Route | +|-----------|-------| +| `CreateKey` | `POST /keys` | +| `ListKeys` | `GET /keys` | +| `GetKey` | `GET /keys/{keyId}` | +| `UpdateKey` | `PUT /keys/{keyId}` | +| `ScheduleKeyDeletion` | `POST /keys/{keyId}/actions/scheduleDeletion` | +| `CancelKeyDeletion` | `POST /keys/{keyId}/actions/cancelDeletion` | +| `ChangeKeyCompartment` | `POST /keys/{keyId}/actions/changeCompartment` | +| `CreateKeyVersion` | `POST /keys/{keyId}/keyVersions` | +| `ListKeyVersions` | `GET /keys/{keyId}/keyVersions` | +| `GetKeyVersion` | `GET /keys/{keyId}/keyVersions/{keyVersionId}` | + +The KMS management endpoint is per-vault in real OCI, where the vault is +addressed by host. CloudEmu serves every vault from one host, so `/keys` takes +`vaultId` as a query parameter instead. + +#### Secrets (`/20180608`) + +| Operation | Route | +|-----------|-------| +| `CreateSecret` | `POST /secrets` | +| `ListSecrets` | `GET /secrets` | +| `GetSecret` | `GET /secrets/{secretId}` | +| `UpdateSecret` | `PUT /secrets/{secretId}` | +| `GetSecretByName` | `GET /secrets/actions/getByName` | +| `ScheduleSecretDeletion` | `POST /secrets/{secretId}/actions/scheduleDeletion` | +| `CancelSecretDeletion` | `POST /secrets/{secretId}/actions/cancelDeletion` | +| `ChangeSecretCompartment` | `POST /secrets/{secretId}/actions/changeCompartment` | +| `ListSecretVersions` | `GET /secrets/{secretId}/versions` | +| `GetSecretVersion` | `GET /secrets/{secretId}/versions/{versionNumber}` | +| `ScheduleSecretVersionDeletion` | `POST /secrets/{secretId}/versions/{n}/actions/scheduleDeletion` | +| `CancelSecretVersionDeletion` | `POST /secrets/{secretId}/versions/{n}/actions/cancelDeletion` | + +#### Secret bundles — the retrieval data plane (`/20190301`) + +| Operation | Route | +|-----------|-------| +| `GetSecretBundle` | `GET /secretbundles/{secretId}` | +| `ListSecretBundleVersions` | `GET /secretbundles/{secretId}/versions` | +| `GetSecretBundleByName` | `GET /secretbundles/actions/getByName` | + +A bundle read is the only way to get a secret's *value* back; the management +routes above return metadata only. The version is named by exactly one of +`versionNumber`, `secretVersionName` or `stage` — more than one is rejected +rather than silently ranked. + +**Total: 32 operations** + +#### Behavior + +There is no secret outside a vault, and no secret without a master encryption +key, so the portable `CreateSecret` mints a default vault and AES key on first +use and puts every portable secret there. Portable *reads*, though, reach every +vault, so a secret created through the OCI-shaped surface is addressable +portably too. + +OCI scopes secret names to the vault, so the same name in two vaults is two +different secrets and that reach makes a bare name ambiguous. Rather than +silently picking one, every portable operation that keys by name fails with +`InvalidArgument` naming both vaults, and `CreateSecret` refuses to mint a name +another vault already holds. `ListSecrets` is unaffected — it addresses nothing +by name. With one vault in play, which is all the AWS, Azure and GCP secret +mocks can have, none of this is observable. + +The KMS surface and the secret surface report different live states, as real +OCI does: a key or key version is `ENABLED` and never `ACTIVE`, while a vault or +secret is `ACTIVE` and never `ENABLED`. This matters to consumers — +`terraform-provider-oci`'s `oci_kms_key` waits for `ENABLED` after a create or +rotate. `enableKey` / `disableKey` and the `ENABLING` / `DISABLING` / `DISABLED` +states are not served, so a key's lifecycle here is `ENABLED ⇄ PENDING_DELETION`. + +Deletion is scheduled, never immediate. A vault, key, secret or secret version +moves to `PENDING_DELETION` and stays there — nothing reaps it — until the +deletion is cancelled. A secret pending deletion releases its name, so the same +name can be taken again in that vault; cancelling then fails rather than +producing two live secrets alike. The portable `DeleteSecret` schedules at the +soonest OCI permits, one day out, and the portable operations treat the secret +as gone from that moment, which is the same soft-delete the AWS Secrets Manager +mock exposes. + +Secret versions are numbered, and each carries a stage: `CURRENT`, `PENDING`, +`PREVIOUS`, `LATEST` or `DEPRECATED`. Adding a version stages it, and promoting +one to `CURRENT` — via `UpdateSecret`'s `currentVersionNumber` — is how OCI +finishes a rotation staged as `PENDING`. The portable version identifier is +OCI's version number, so a non-numeric one is rejected. + +Every list route requires `compartmentId` except the key-version, secret-version +and bundle routes, whose parent already names one. All paginate with `limit` / +`page`, returning the cursor as `opc-next-page`. The mutations real OCI runs +asynchronously record a work request and stamp `opc-work-request-id`; +`changeCompartment` answers `202` with nothing else, so a waiter must poll. + +`definedTags` is rejected rather than accepted and dropped, since CloudEmu +models no tag namespaces — use `freeformTags`. `autoKeyRotationDetails`, +`externalKeyReference` and `desiredState` are rejected the same way. + +The KMS crypto endpoint — `encrypt`, `decrypt`, `sign`, `verify`, `exportKey` +and `generateDataEncryptionKey` — is claimed only to answer `501` naming the +gap rather than leaving a caller with a bare `404`. CloudEmu records master +encryption keys but stores no key material, so there is no ciphertext to invent. + --- ## 13. Logging diff --git a/providers/oci/oci.go b/providers/oci/oci.go index 0036a9a58..a53b7a42d 100644 --- a/providers/oci/oci.go +++ b/providers/oci/oci.go @@ -6,6 +6,7 @@ import ( "github.com/stackshy/cloudemu/v2/internal/snapshot" "github.com/stackshy/cloudemu/v2/providers/oci/identity" "github.com/stackshy/cloudemu/v2/providers/oci/monitoring" + vaultprovider "github.com/stackshy/cloudemu/v2/providers/oci/vault" vcnprovider "github.com/stackshy/cloudemu/v2/providers/oci/vcn" cachedriver "github.com/stackshy/cloudemu/v2/services/cache/driver" computedriver "github.com/stackshy/cloudemu/v2/services/compute/driver" @@ -76,6 +77,7 @@ func New(opts ...config.Option) *Provider { } p.Identity = identity.New(o) p.VCN = vcnprovider.New(o) + p.Vault = vaultprovider.New(o) p.Monitoring = monitoring.New(o) diff --git a/providers/oci/vault/key.go b/providers/oci/vault/key.go new file mode 100644 index 000000000..9e83f3e66 --- /dev/null +++ b/providers/oci/vault/key.go @@ -0,0 +1,448 @@ +package vault + +import ( + "slices" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +// Key protection modes. +const ( + ProtectionModeHSM = "HSM" + ProtectionModeSoftware = "SOFTWARE" +) + +// Key algorithms. +const ( + AlgorithmAES = "AES" + AlgorithmRSA = "RSA" + AlgorithmECDSA = "ECDSA" +) + +// Permitted key lengths in bytes, per algorithm. +// +//nolint:gochecknoglobals // a lookup table, read-only after init. +var keyLengths = map[string][]int{ + AlgorithmAES: {16, 24, 32}, + AlgorithmRSA: {256, 384, 512}, + AlgorithmECDSA: {32, 48, 66}, +} + +// KeyShape is the algorithm and size of a master encryption key. +type KeyShape struct { + Algorithm string + Length int + // CurveID names the curve an ECDSA key uses; empty for AES and RSA. + CurveID string +} + +// KeySpec describes a master encryption key to create. +type KeySpec struct { + CompartmentID string + VaultID string + DisplayName string + Shape KeyShape + // ProtectionMode is HSM or SOFTWARE; empty means HSM, OCI's default. + ProtectionMode string + FreeformTags map[string]string +} + +// KeyInfo describes a master encryption key. +type KeyInfo struct { + ID string + CompartmentID string + VaultID string + DisplayName string + Shape KeyShape + ProtectionMode string + LifecycleState string + CurrentKeyVersion string + TimeCreated string + TimeOfDeletion string + FreeformTags map[string]string +} + +// KeyVersionInfo describes one version of a master encryption key. Rotating a +// key is creating a new version of it. +type KeyVersionInfo struct { + ID string + KeyID string + VaultID string + CompartmentID string + LifecycleState string + TimeCreated string +} + +type keyData struct { + ID string + VaultID string + DisplayName string + Shape KeyShape + ProtectionMode string + LifecycleState string + TimeCreated string + TimeOfDeletion string + CurrentVersion string + Scope scope.Scope + FreeformTags map[string]string +} + +type keyVersionData struct { + ID string + KeyID string + VaultID string + TimeCreated string + Scope scope.Scope +} + +// CreateKey creates a master encryption key in a vault, along with its first +// key version. +func (m *Mock) CreateKey(spec *KeySpec) (*KeyInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if spec.DisplayName == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "displayName is required") + } + + if spec.VaultID == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "vaultId is required") + } + + if err := m.requireActiveVaultLocked(spec.VaultID); err != nil { + return nil, err + } + + mode, err := protectionMode(spec.ProtectionMode) + if err != nil { + return nil, err + } + + if err := validateShape(spec.Shape); err != nil { + return nil, err + } + + k := m.newKeyLocked(spec, mode) + + info := toKeyInfo(k) + + return &info, nil +} + +// newKeyLocked stores a key built from spec and mints its first version. +func (m *Mock) newKeyLocked(spec *KeySpec, mode string) *keyData { + id := m.newOCID(typeKey) + k := &keyData{ + ID: id, + VaultID: spec.VaultID, + DisplayName: spec.DisplayName, + Shape: spec.Shape, + ProtectionMode: mode, + LifecycleState: StateEnabled, + TimeCreated: m.now(), + Scope: scope.Scope{Compartment: m.compartmentOr(spec.CompartmentID)}, + FreeformTags: copyTags(spec.FreeformTags), + } + + m.keys.Set(id, k) + k.CurrentVersion = m.newKeyVersionLocked(k).ID + + return k +} + +// newKeyVersionLocked mints a key version and points the key at it. +func (m *Mock) newKeyVersionLocked(k *keyData) *keyVersionData { + kv := &keyVersionData{ + ID: m.newOCID(typeKeyVersion), + KeyID: k.ID, + VaultID: k.VaultID, + TimeCreated: m.now(), + Scope: k.Scope, + } + + m.keyVersions.Set(kv.ID, kv) + + return kv +} + +// GetKey returns a key by OCID. +func (m *Mock) GetKey(id string) (*KeyInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + k, err := m.keyLocked(id) + if err != nil { + return nil, err + } + + info := toKeyInfo(k) + + return &info, nil +} + +// ListKeys returns the keys in a compartment, further filtered to one vault +// when vaultID is non-empty, ordered by OCID. +func (m *Mock) ListKeys(compartmentID, vaultID string) ([]KeyInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + filter := scope.Scope{Compartment: compartmentID} + out := make([]KeyInfo, 0, m.keys.Len()) + + for _, k := range m.keys.SortedValues() { + if !k.Scope.Matches(filter) || (vaultID != "" && k.VaultID != vaultID) { + continue + } + + out = append(out, toKeyInfo(k)) + } + + return out, nil +} + +// UpdateKey replaces a key's display name and freeform tags. +func (m *Mock) UpdateKey(id string, upd Update) (*KeyInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + k, err := m.keyLocked(id) + if err != nil { + return nil, err + } + + if upd.DisplayName != nil { + k.DisplayName = *upd.DisplayName + } + + if upd.FreeformTags != nil { + k.FreeformTags = copyTags(upd.FreeformTags) + } + + info := toKeyInfo(k) + + return &info, nil +} + +// ScheduleKeyDeletion marks a key for deletion at the given time, which must +// fall between 7 and 30 days out. +func (m *Mock) ScheduleKeyDeletion(id, at string) (*KeyInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + k, err := m.keyLocked(id) + if err != nil { + return nil, err + } + + if k.LifecycleState == StatePendingDeletion { + return nil, cerrors.Newf(cerrors.FailedPrecondition, "key %s is already scheduled for deletion", id) + } + + when, err := m.deletionTime(at, minKeyDeletionDays) + if err != nil { + return nil, err + } + + k.LifecycleState = StatePendingDeletion + k.TimeOfDeletion = when + + info := toKeyInfo(k) + + return &info, nil +} + +// CancelKeyDeletion returns a key scheduled for deletion to ACTIVE. +func (m *Mock) CancelKeyDeletion(id string) (*KeyInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + k, err := m.keyLocked(id) + if err != nil { + return nil, err + } + + if k.LifecycleState != StatePendingDeletion { + return nil, cerrors.Newf(cerrors.FailedPrecondition, "key %s is not scheduled for deletion", id) + } + + k.LifecycleState = StateEnabled + k.TimeOfDeletion = "" + + info := toKeyInfo(k) + + return &info, nil +} + +// ChangeKeyCompartment moves a key to another compartment. +func (m *Mock) ChangeKeyCompartment(id, compartmentID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if compartmentID == "" { + return cerrors.New(cerrors.InvalidArgument, "compartmentId is required") + } + + k, err := m.keyLocked(id) + if err != nil { + return err + } + + k.Scope = scope.Scope{Compartment: compartmentID} + + return nil +} + +// KeyCompartment returns the compartment a key lives in, for the work request +// the wire layer records. +func (m *Mock) KeyCompartment(id string) string { + m.mu.RLock() + defer m.mu.RUnlock() + + k, ok := m.keys.Get(id) + if !ok { + return "" + } + + return k.Scope.Compartment +} + +// CreateKeyVersion rotates a key: it mints a new version and makes it the +// key's current one. Earlier versions stay readable, as in real OCI. +func (m *Mock) CreateKeyVersion(keyID string) (*KeyVersionInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + k, err := m.keyLocked(keyID) + if err != nil { + return nil, err + } + + if k.LifecycleState != StateEnabled { + return nil, cerrors.Newf(cerrors.FailedPrecondition, "key %s is %s", keyID, k.LifecycleState) + } + + kv := m.newKeyVersionLocked(k) + k.CurrentVersion = kv.ID + + info := toKeyVersionInfo(kv) + + return &info, nil +} + +// GetKeyVersion returns one version of a key. +func (m *Mock) GetKeyVersion(keyID, versionID string) (*KeyVersionInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if _, err := m.keyLocked(keyID); err != nil { + return nil, err + } + + kv, ok := m.keyVersions.Get(versionID) + if !ok || kv.KeyID != keyID { + return nil, cerrors.Newf(cerrors.NotFound, "key version %s not found for key %s", versionID, keyID) + } + + info := toKeyVersionInfo(kv) + + return &info, nil +} + +// ListKeyVersions returns every version of a key, oldest first. Real OCI takes +// no compartmentId here — the key already names one. +func (m *Mock) ListKeyVersions(keyID string) ([]KeyVersionInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if _, err := m.keyLocked(keyID); err != nil { + return nil, err + } + + out := make([]KeyVersionInfo, 0, m.keyVersions.Len()) + + for _, kv := range m.keyVersions.SortedValues() { + if kv.KeyID != keyID { + continue + } + + out = append(out, toKeyVersionInfo(kv)) + } + + return out, nil +} + +// keyLocked reads a key, reporting OCI's not-found for an unknown OCID. +func (m *Mock) keyLocked(id string) (*keyData, error) { + k, ok := m.keys.Get(id) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "key %s not found", id) + } + + return k, nil +} + +// protectionMode validates the mode a key is stored under. +func protectionMode(mode string) (string, error) { + switch mode { + case "": + return ProtectionModeHSM, nil + case ProtectionModeHSM, ProtectionModeSoftware: + return mode, nil + default: + return "", cerrors.Newf(cerrors.InvalidArgument, + "protectionMode %q is not one of %s, %s", mode, ProtectionModeHSM, ProtectionModeSoftware) + } +} + +// validateShape checks a key's algorithm, length and curve. +func validateShape(shape KeyShape) error { + lengths, ok := keyLengths[shape.Algorithm] + if !ok { + return cerrors.Newf(cerrors.InvalidArgument, + "keyShape.algorithm %q is not one of %s, %s, %s", + shape.Algorithm, AlgorithmAES, AlgorithmRSA, AlgorithmECDSA) + } + + if !slices.Contains(lengths, shape.Length) { + return cerrors.Newf(cerrors.InvalidArgument, + "keyShape.length %d is not one of %v for algorithm %s", shape.Length, lengths, shape.Algorithm) + } + + if shape.Algorithm == AlgorithmECDSA && shape.CurveID == "" { + return cerrors.New(cerrors.InvalidArgument, "keyShape.curveId is required for an ECDSA key") + } + + if shape.Algorithm != AlgorithmECDSA && shape.CurveID != "" { + return cerrors.Newf(cerrors.InvalidArgument, "keyShape.curveId does not apply to a %s key", shape.Algorithm) + } + + return nil +} + +func toKeyInfo(k *keyData) KeyInfo { + return KeyInfo{ + ID: k.ID, + CompartmentID: k.Scope.Compartment, + VaultID: k.VaultID, + DisplayName: k.DisplayName, + Shape: k.Shape, + ProtectionMode: k.ProtectionMode, + LifecycleState: k.LifecycleState, + CurrentKeyVersion: k.CurrentVersion, + TimeCreated: k.TimeCreated, + TimeOfDeletion: k.TimeOfDeletion, + FreeformTags: copyTags(k.FreeformTags), + } +} + +func toKeyVersionInfo(kv *keyVersionData) KeyVersionInfo { + return KeyVersionInfo{ + ID: kv.ID, + KeyID: kv.KeyID, + VaultID: kv.VaultID, + CompartmentID: kv.Scope.Compartment, + LifecycleState: StateEnabled, + TimeCreated: kv.TimeCreated, + } +} diff --git a/providers/oci/vault/key_test.go b/providers/oci/vault/key_test.go new file mode 100644 index 000000000..857311bb5 --- /dev/null +++ b/providers/oci/vault/key_test.go @@ -0,0 +1,257 @@ +package vault + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +func TestCreateKey(t *testing.T) { + tests := []struct { + name string + shape KeyShape + mode string + expectErr cerrors.Code + expectMode string + }{ + { + name: "AES key defaults to HSM", + shape: KeyShape{Algorithm: AlgorithmAES, Length: 32}, + expectMode: ProtectionModeHSM, + }, + { + name: "software RSA key", + shape: KeyShape{Algorithm: AlgorithmRSA, Length: 256}, + mode: ProtectionModeSoftware, + expectMode: ProtectionModeSoftware, + }, + { + name: "ECDSA key with a curve", + shape: KeyShape{Algorithm: AlgorithmECDSA, Length: 32, CurveID: "NIST_P256"}, + expectMode: ProtectionModeHSM, + }, + { + name: "unknown algorithm", + shape: KeyShape{Algorithm: "TWOFISH", Length: 32}, + expectErr: cerrors.InvalidArgument, + }, + { + name: "bad length for the algorithm", + shape: KeyShape{Algorithm: AlgorithmAES, Length: 17}, + expectErr: cerrors.InvalidArgument, + }, + { + name: "ECDSA without a curve", + shape: KeyShape{Algorithm: AlgorithmECDSA, Length: 32}, + expectErr: cerrors.InvalidArgument, + }, + { + name: "curve on a non-ECDSA key", + shape: KeyShape{Algorithm: AlgorithmAES, Length: 32, CurveID: "NIST_P256"}, + expectErr: cerrors.InvalidArgument, + }, + { + name: "unknown protection mode", + shape: KeyShape{Algorithm: AlgorithmAES, Length: 32}, + mode: "PAPER", + expectErr: cerrors.InvalidArgument, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newTestMock() + + v, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) + require.NoError(t, err) + + info, err := m.CreateKey(&KeySpec{ + CompartmentID: testCompartment, + VaultID: v.ID, + DisplayName: "k", + Shape: tc.shape, + ProtectionMode: tc.mode, + }) + if tc.expectErr != cerrors.OK { + require.Error(t, err) + assert.Equal(t, tc.expectErr, cerrors.GetCode(err)) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectMode, info.ProtectionMode) + assert.Equal(t, StateEnabled, info.LifecycleState) + assert.True(t, strings.HasPrefix(info.ID, "ocid1.key.oc1.iad."), "got %q", info.ID) + assert.True(t, strings.HasPrefix(info.CurrentKeyVersion, "ocid1.keyversion.oc1.iad."), + "got %q", info.CurrentKeyVersion) + }) + } +} + +func TestCreateKeyRequiresAnActiveVault(t *testing.T) { + m := newTestMock() + + v, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) + require.NoError(t, err) + + _, err = m.ScheduleVaultDeletion(v.ID, "") + require.NoError(t, err) + + shape := KeyShape{Algorithm: AlgorithmAES, Length: 32} + + _, err = m.CreateKey(&KeySpec{CompartmentID: testCompartment, VaultID: v.ID, DisplayName: "k", Shape: shape}) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + _, err = m.CreateKey(&KeySpec{CompartmentID: testCompartment, VaultID: "ocid1.vault.oc1.iad.x", DisplayName: "k", Shape: shape}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + _, err = m.CreateKey(&KeySpec{CompartmentID: testCompartment, DisplayName: "k", Shape: shape}) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + + _, err = m.CreateKey(&KeySpec{CompartmentID: testCompartment, VaultID: v.ID, Shape: shape}) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) +} + +func TestListKeysFiltersByCompartmentAndVault(t *testing.T) { + m := newTestMock() + + vaultA, _ := newVaultAndKey(t, m, testCompartment) + vaultB, _ := newVaultAndKey(t, m, otherCompart) + + mine, err := m.ListKeys(testCompartment, "") + require.NoError(t, err) + require.Len(t, mine, 1) + assert.Equal(t, vaultA, mine[0].VaultID) + + byVault, err := m.ListKeys(otherCompart, vaultB) + require.NoError(t, err) + assert.Len(t, byVault, 1) + + crossed, err := m.ListKeys(testCompartment, vaultB) + require.NoError(t, err) + assert.Empty(t, crossed) +} + +func TestKeyScheduledDeletionAndCancellation(t *testing.T) { + m := newTestMock() + _, keyID := newVaultAndKey(t, m, testCompartment) + + scheduled, err := m.ScheduleKeyDeletion(keyID, "") + require.NoError(t, err) + assert.Equal(t, StatePendingDeletion, scheduled.LifecycleState) + assert.Equal(t, "2026-01-31T00:00:00Z", scheduled.TimeOfDeletion) + + _, err = m.ScheduleKeyDeletion(keyID, "") + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + // A key pending deletion cannot be rotated. + _, err = m.CreateKeyVersion(keyID) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + restored, err := m.CancelKeyDeletion(keyID) + require.NoError(t, err) + assert.Equal(t, StateEnabled, restored.LifecycleState) + + _, err = m.CancelKeyDeletion(keyID) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) +} + +func TestKeyRotationCreatesVersions(t *testing.T) { + m := newTestMock() + _, keyID := newVaultAndKey(t, m, testCompartment) + + first, err := m.GetKey(keyID) + require.NoError(t, err) + + rotated, err := m.CreateKeyVersion(keyID) + require.NoError(t, err) + assert.NotEqual(t, first.CurrentKeyVersion, rotated.ID) + + after, err := m.GetKey(keyID) + require.NoError(t, err) + assert.Equal(t, rotated.ID, after.CurrentKeyVersion) + + versions, err := m.ListKeyVersions(keyID) + require.NoError(t, err) + require.Len(t, versions, 2) + assert.Equal(t, first.CurrentKeyVersion, versions[0].ID) + assert.Equal(t, rotated.ID, versions[1].ID) + + got, err := m.GetKeyVersion(keyID, rotated.ID) + require.NoError(t, err) + assert.Equal(t, keyID, got.KeyID) + + _, err = m.GetKeyVersion(keyID, "ocid1.keyversion.oc1.iad.missing") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + _, err = m.ListKeyVersions("ocid1.key.oc1.iad.missing") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestUpdateAndMoveKey(t *testing.T) { + m := newTestMock() + _, keyID := newVaultAndKey(t, m, testCompartment) + + name := "renamed" + + got, err := m.UpdateKey(keyID, Update{DisplayName: &name, FreeformTags: map[string]string{"a": "b"}}) + require.NoError(t, err) + assert.Equal(t, "renamed", got.DisplayName) + assert.Equal(t, "b", got.FreeformTags["a"]) + + require.NoError(t, m.ChangeKeyCompartment(keyID, otherCompart)) + assert.Equal(t, otherCompart, m.KeyCompartment(keyID)) + + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(m.ChangeKeyCompartment(keyID, ""))) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.ChangeKeyCompartment("ocid1.key.oc1.iad.x", otherCompart))) + assert.Empty(t, m.KeyCompartment("ocid1.key.oc1.iad.x")) + + _, err = m.UpdateKey("ocid1.key.oc1.iad.x", Update{}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + _, err = m.GetKey("ocid1.key.oc1.iad.x") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +// The KMS surface and the secret surface report different live states. Per the +// SDK's KeyLifecycleStateEnum a key and a key version are ENABLED and never +// ACTIVE, while a vault and a secret are ACTIVE and never ENABLED — +// terraform-provider-oci's oci_kms_key waits on ENABLED, so a key reporting +// ACTIVE hangs the apply. +func TestKeyStatesAreEnabledAndResourceStatesAreActive(t *testing.T) { + m := newTestMock() + + v, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) + require.NoError(t, err) + assert.Equal(t, StateActive, v.LifecycleState, "a vault is ACTIVE") + + k, err := m.CreateKey(&KeySpec{ + CompartmentID: testCompartment, VaultID: v.ID, DisplayName: "k", + Shape: KeyShape{Algorithm: AlgorithmAES, Length: 32}, + }) + require.NoError(t, err) + assert.Equal(t, StateEnabled, k.LifecycleState, "a key is ENABLED, never ACTIVE") + + kv, err := m.GetKeyVersion(k.ID, k.CurrentKeyVersion) + require.NoError(t, err) + assert.Equal(t, StateEnabled, kv.LifecycleState, "a key version is ENABLED, never ACTIVE") + + rotated, err := m.CreateKeyVersion(k.ID) + require.NoError(t, err) + assert.Equal(t, StateEnabled, rotated.LifecycleState, "a rotated key version is ENABLED") + + s, err := m.CreateOCISecret(&SecretSpec{ + CompartmentID: testCompartment, VaultID: v.ID, KeyID: k.ID, + Name: "s", Content: []byte("v"), + }) + require.NoError(t, err) + assert.Equal(t, StateActive, s.LifecycleState, "a secret is ACTIVE") + + // The two vocabularies stay distinct. + assert.NotEqual(t, StateActive, StateEnabled) +} diff --git a/providers/oci/vault/portable.go b/providers/oci/vault/portable.go new file mode 100644 index 000000000..76efbd219 --- /dev/null +++ b/providers/oci/vault/portable.go @@ -0,0 +1,264 @@ +package vault + +import ( + "context" + "strconv" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/secrets/driver" +) + +// The portable secrets driver, mapped onto OCI Vault. +// +// OCI has no secret outside a vault, so the portable create mints a vault and +// a master encryption key on first use and puts every portable secret there. +// +// Reads, though, reach every vault, so a secret made through the OCI-shaped +// surface is addressable portably too. OCI scopes secret names to the vault, +// so that reach makes a bare name ambiguous when two vaults both hold it: the +// portable operations reject such a name rather than silently picking one, and +// the portable create refuses to mint a name that another vault already holds. +// With a single vault in play — the ordinary case, and the only one the AWS, +// Azure and GCP secret mocks can have — none of this is observable. +// +// OCI never deletes a secret outright: DeleteSecret schedules the deletion at +// the soonest OCI permits, one day out, and the secret moves to +// PENDING_DELETION. The portable operations then treat it as gone — Get, List +// and the value operations report not-found — while the OCI-shaped surface +// still lists it and CancelSecretDeletion can bring it back, which is the +// same soft-delete the AWS Secrets Manager mock exposes. + +// CreateSecret creates a secret with an initial value in the portable driver's +// vault. +// +//nolint:gocritic // hugeParam: driver.Secrets fixes this signature; cfg cannot be a pointer. +func (m *Mock) CreateSecret( + _ context.Context, cfg driver.SecretConfig, value []byte, +) (*driver.SecretInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if cfg.Name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "secret name is required") + } + + // A name another vault already holds would be created here only to be + // unreadable through this surface, so it is refused up front. + if other, ok := m.liveSecretByNameLocked(cfg.Name); ok { + return nil, cerrors.Newf(cerrors.AlreadyExists, + "secret %q already exists in vault %s", cfg.Name, other.VaultID) + } + + vaultID, keyID := m.defaultVaultLocked() + + spec := &SecretSpec{ + VaultID: vaultID, + KeyID: keyID, + Name: cfg.Name, + Description: cfg.Description, + Content: value, + FreeformTags: cfg.Tags, + } + + if err := m.validateSecretSpecLocked(spec); err != nil { + return nil, err + } + + info := toPortableInfo(m.newSecretLocked(spec)) + + return &info, nil +} + +// DeleteSecret schedules the secret's deletion at the soonest OCI permits. The +// secret keeps its OCID and versions, and CancelSecretDeletion restores it. +func (m *Mock) DeleteSecret(_ context.Context, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + s, err := m.portableSecretLocked(name) + if err != nil { + return err + } + + return scheduleSecret(s, m.earliestDeletion(minSecretDeletionDays)) +} + +// GetSecret retrieves secret metadata by name. +func (m *Mock) GetSecret(_ context.Context, name string) (*driver.SecretInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + s, err := m.portableSecretLocked(name) + if err != nil { + return nil, err + } + + info := toPortableInfo(s) + + return &info, nil +} + +// ListSecrets returns every secret not pending deletion, ordered by OCID, +// across every vault — the same reach the by-name lookups have. +func (m *Mock) ListSecrets(_ context.Context) ([]driver.SecretInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + out := make([]driver.SecretInfo, 0, m.secrets.Len()) + + for _, s := range m.secrets.SortedValues() { + if s.LifecycleState != StateActive { + continue + } + + out = append(out, toPortableInfo(s)) + } + + return out, nil +} + +// PutSecretValue writes a new version of a secret and makes it CURRENT. +func (m *Mock) PutSecretValue(_ context.Context, name string, value []byte) (*driver.SecretVersion, error) { + m.mu.Lock() + defer m.mu.Unlock() + + s, err := m.portableSecretLocked(name) + if err != nil { + return nil, err + } + + v := m.addVersionLocked(s, value, "", StageCurrent) + + return toPortableVersion(v, s.CurrentVersion), nil +} + +// GetSecretValue reads one version of a secret. An empty versionID reads the +// CURRENT version; otherwise versionID is OCI's version number. +func (m *Mock) GetSecretValue(_ context.Context, name, versionID string) (*driver.SecretVersion, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + s, err := m.portableSecretLocked(name) + if err != nil { + return nil, err + } + + sel, err := portableSelector(versionID) + if err != nil { + return nil, err + } + + v, err := selectVersion(s, sel) + if err != nil { + return nil, err + } + + return toPortableVersion(v, s.CurrentVersion), nil +} + +// ListSecretVersions returns every version of a secret, oldest first. +func (m *Mock) ListSecretVersions(_ context.Context, name string) ([]driver.SecretVersion, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + s, err := m.portableSecretLocked(name) + if err != nil { + return nil, err + } + + out := make([]driver.SecretVersion, 0, len(s.Versions)) + for _, v := range s.Versions { + out = append(out, *toPortableVersion(v, s.CurrentVersion)) + } + + return out, nil +} + +// portableSecretLocked resolves a bare portable name across every vault, since +// a secret made through the OCI-shaped surface is as addressable as one the +// portable driver made. OCI scopes secret names to the vault, so one name can +// reach two secrets; rather than silently picking either, an ambiguous name is +// rejected naming both vaults. +func (m *Mock) portableSecretLocked(name string) (*secretData, error) { + var found *secretData + + for _, s := range m.secrets.SortedValues() { + if s.Name != name || s.LifecycleState != StateActive { + continue + } + + if found != nil { + return nil, cerrors.Newf(cerrors.InvalidArgument, + "secret %q is ambiguous: it exists in vaults %s and %s", name, found.VaultID, s.VaultID) + } + + found = s + } + + if found == nil { + return nil, cerrors.Newf(cerrors.NotFound, "secret %q not found", name) + } + + return found, nil +} + +// defaultVaultLocked returns the vault and key the portable driver stores its +// secrets in, creating them on first use. +func (m *Mock) defaultVaultLocked() (vaultID, keyID string) { + if m.vaults.Has(m.defaultVaultID) && m.keys.Has(m.defaultKeyID) { + return m.defaultVaultID, m.defaultKeyID + } + + v := m.newVaultLocked(&VaultSpec{DisplayName: defaultVaultName}, VaultTypeDefault) + k := m.newKeyLocked(&KeySpec{ + VaultID: v.ID, + DisplayName: defaultKeyName, + Shape: KeyShape{Algorithm: AlgorithmAES, Length: 32}, + }, ProtectionModeHSM) + + m.defaultVaultID = v.ID + m.defaultKeyID = k.ID + + return v.ID, k.ID +} + +// portableSelector turns the portable version identifier into a bundle +// selector. OCI numbers versions, so a non-numeric identifier cannot name one. +func portableSelector(versionID string) (BundleSelector, error) { + if versionID == "" { + return BundleSelector{}, nil + } + + n, err := strconv.ParseInt(versionID, 10, 64) + if err != nil { + return BundleSelector{}, cerrors.Newf(cerrors.InvalidArgument, + "version %q is not an OCI secret version number", versionID) + } + + return BundleSelector{VersionNumber: &n}, nil +} + +// toPortableInfo projects a secret onto the portable shape. OCI has no ARN or +// self link, so the OCID serves as both the identifier and the resource ID. +func toPortableInfo(s *secretData) driver.SecretInfo { + return driver.SecretInfo{ + ID: s.ID, + Name: s.Name, + ResourceID: s.ID, + Description: s.Description, + CreatedAt: s.TimeCreated, + UpdatedAt: s.TimeUpdated, + Tags: copyTags(s.FreeformTags), + } +} + +// toPortableVersion projects a version onto the portable shape, whose version +// identifier is OCI's version number. +func toPortableVersion(v *versionData, current int64) *driver.SecretVersion { + return &driver.SecretVersion{ + VersionID: strconv.FormatInt(v.Number, 10), + Value: append([]byte(nil), v.Content...), + CreatedAt: v.TimeCreated, + Current: v.Number == current, + } +} diff --git a/providers/oci/vault/portable_test.go b/providers/oci/vault/portable_test.go new file mode 100644 index 000000000..a7e02ea29 --- /dev/null +++ b/providers/oci/vault/portable_test.go @@ -0,0 +1,297 @@ +package vault + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/secrets/driver" +) + +func TestPortableCreateSecretMintsTheDefaultVault(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + info, err := m.CreateSecret(ctx, driver.SecretConfig{ + Name: "api-key", + Description: "the api key", + Tags: map[string]string{"env": "dev"}, + }, []byte("s3cret")) + require.NoError(t, err) + + assert.True(t, strings.HasPrefix(info.ID, "ocid1.vaultsecret.oc1.iad."), "got %q", info.ID) + assert.Equal(t, info.ID, info.ResourceID) + assert.Equal(t, "api-key", info.Name) + assert.Equal(t, "the api key", info.Description) + assert.Equal(t, "dev", info.Tags["env"]) + assert.NotEmpty(t, info.CreatedAt) + assert.NotEmpty(t, info.UpdatedAt) + + // The vault and key OCI requires were created on demand. + vaults, err := m.ListVaults(testCompartment) + require.NoError(t, err) + require.Len(t, vaults, 1) + assert.Equal(t, defaultVaultName, vaults[0].DisplayName) + + keys, err := m.ListKeys(testCompartment, vaults[0].ID) + require.NoError(t, err) + require.Len(t, keys, 1) + assert.Equal(t, defaultKeyName, keys[0].DisplayName) + + // A second secret reuses them rather than minting another vault. + _, err = m.CreateSecret(ctx, driver.SecretConfig{Name: "second"}, []byte("v")) + require.NoError(t, err) + + vaults, err = m.ListVaults(testCompartment) + require.NoError(t, err) + assert.Len(t, vaults, 1) +} + +func TestPortableCreateSecretErrors(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, err := m.CreateSecret(ctx, driver.SecretConfig{}, []byte("v")) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + + _, err = m.CreateSecret(ctx, driver.SecretConfig{Name: "dup"}, []byte("a")) + require.NoError(t, err) + + _, err = m.CreateSecret(ctx, driver.SecretConfig{Name: "dup"}, []byte("b")) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) +} + +func TestPortableGetAndListSecrets(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + for _, name := range []string{"a", "b"} { + _, err := m.CreateSecret(ctx, driver.SecretConfig{Name: name}, []byte(name)) + require.NoError(t, err) + } + + got, err := m.GetSecret(ctx, "a") + require.NoError(t, err) + assert.Equal(t, "a", got.Name) + + _, err = m.GetSecret(ctx, "missing") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + list, err := m.ListSecrets(ctx) + require.NoError(t, err) + assert.Len(t, list, 2) +} + +// The portable delete maps onto OCI's scheduled deletion: the secret moves to +// PENDING_DELETION at the soonest OCI permits and the portable operations then +// treat it as gone, while the OCI-shaped surface can still bring it back. +func TestPortableDeleteSchedulesDeletion(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + created, err := m.CreateSecret(ctx, driver.SecretConfig{Name: "soft"}, []byte("v")) + require.NoError(t, err) + + require.NoError(t, m.DeleteSecret(ctx, "soft")) + + oci, err := m.GetOCISecret(created.ID) + require.NoError(t, err) + assert.Equal(t, StatePendingDeletion, oci.LifecycleState) + assert.Equal(t, "2026-01-02T00:00:00Z", oci.TimeOfDeletion) + + // Gone as far as every portable operation is concerned. + _, err = m.GetSecret(ctx, "soft") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + list, err := m.ListSecrets(ctx) + require.NoError(t, err) + assert.Empty(t, list) + + _, err = m.PutSecretValue(ctx, "soft", []byte("v2")) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + _, err = m.GetSecretValue(ctx, "soft", "") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + _, err = m.ListSecretVersions(ctx, "soft") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.DeleteSecret(ctx, "soft"))) + + // Cancelling the OCI-side deletion brings it back to the portable view. + _, err = m.CancelOCISecretDeletion(created.ID) + require.NoError(t, err) + + back, err := m.GetSecret(ctx, "soft") + require.NoError(t, err) + assert.Equal(t, created.ID, back.ID) +} + +func TestPortableSecretValues(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + _, err := m.CreateSecret(ctx, driver.SecretConfig{Name: "rotated"}, []byte("one")) + require.NoError(t, err) + + second, err := m.PutSecretValue(ctx, "rotated", []byte("two")) + require.NoError(t, err) + assert.Equal(t, "2", second.VersionID) + assert.True(t, second.Current) + + current, err := m.GetSecretValue(ctx, "rotated", "") + require.NoError(t, err) + assert.Equal(t, []byte("two"), current.Value) + + first, err := m.GetSecretValue(ctx, "rotated", "1") + require.NoError(t, err) + assert.Equal(t, []byte("one"), first.Value) + assert.False(t, first.Current) + + versions, err := m.ListSecretVersions(ctx, "rotated") + require.NoError(t, err) + require.Len(t, versions, 2) + assert.Equal(t, "1", versions[0].VersionID) + + _, err = m.GetSecretValue(ctx, "rotated", "9") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + // OCI numbers versions, so a non-numeric identifier cannot name one. + _, err = m.GetSecretValue(ctx, "rotated", "AWSCURRENT") + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) +} + +// The portable write goes through the same stage ladder as an OCI update. +func TestPortablePutSecretValueStagesLikeOCI(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + created, err := m.CreateSecret(ctx, driver.SecretConfig{Name: "staged"}, []byte("one")) + require.NoError(t, err) + + _, err = m.PutSecretValue(ctx, "staged", []byte("two")) + require.NoError(t, err) + + assert.Equal(t, map[int64][]string{ + 1: {StagePrevious}, + 2: {StageCurrent, StageLatest}, + }, stagesOf(t, m, created.ID)) +} + +// A secret created through the OCI-shaped surface, in a vault the portable +// driver never made, is still addressable by the portable operations. +func TestPortableReadsReachAnotherVault(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + vaultID, keyID := newVaultAndKey(t, m, testCompartment) + + native, err := m.CreateOCISecret(&SecretSpec{ + CompartmentID: testCompartment, + VaultID: vaultID, KeyID: keyID, + Name: "elsewhere", Content: []byte("native"), + }) + require.NoError(t, err) + + got, err := m.GetSecret(ctx, "elsewhere") + require.NoError(t, err) + assert.Equal(t, native.ID, got.ID) + + v, err := m.GetSecretValue(ctx, "elsewhere", "") + require.NoError(t, err) + assert.Equal(t, []byte("native"), v.Value) + + versions, err := m.ListSecretVersions(ctx, "elsewhere") + require.NoError(t, err) + assert.Len(t, versions, 1) + + list, err := m.ListSecrets(ctx) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, native.ID, list[0].ID) + + // And it is writable through the portable surface. + _, err = m.PutSecretValue(ctx, "elsewhere", []byte("v2")) + require.NoError(t, err) +} + +// OCI scopes secret names to the vault, so one bare name can reach two +// secrets. Every portable operation that keys by name rejects that rather than +// silently picking one, and names both vaults so the caller can tell them apart. +func TestPortableRejectsAnAmbiguousName(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + vaultA, keyA := newVaultAndKey(t, m, testCompartment) + vaultB, keyB := newVaultAndKey(t, m, testCompartment) + + for _, v := range []struct{ vaultID, keyID, value string }{ + {vaultA, keyA, "a"}, {vaultB, keyB, "b"}, + } { + _, err := m.CreateOCISecret(&SecretSpec{ + CompartmentID: testCompartment, + VaultID: v.vaultID, KeyID: v.keyID, + Name: "shared", Content: []byte(v.value), + }) + require.NoError(t, err) + } + + _, err := m.GetSecret(ctx, "shared") + require.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), vaultA) + assert.Contains(t, err.Error(), vaultB) + + _, err = m.GetSecretValue(ctx, "shared", "") + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + + _, err = m.PutSecretValue(ctx, "shared", []byte("v")) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + + _, err = m.ListSecretVersions(ctx, "shared") + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(m.DeleteSecret(ctx, "shared"))) + + // Listing still reports both: it needs no name to address them. + list, err := m.ListSecrets(ctx) + require.NoError(t, err) + assert.Len(t, list, 2) + + // Scheduling one for deletion releases its name, so the other resolves. + byName, err := m.GetOCISecretByName(vaultA, "shared") + require.NoError(t, err) + _, err = m.ScheduleOCISecretDeletion(byName.ID, "") + require.NoError(t, err) + + got, err := m.GetSecret(ctx, "shared") + require.NoError(t, err) + assert.NotEqual(t, byName.ID, got.ID) +} + +// The portable create refuses a name another vault already holds, rather than +// minting a secret its own reads would then call ambiguous. +func TestPortableCreateRefusesANameAnotherVaultHolds(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + vaultID, keyID := newVaultAndKey(t, m, testCompartment) + + _, err := m.CreateOCISecret(&SecretSpec{ + CompartmentID: testCompartment, + VaultID: vaultID, KeyID: keyID, + Name: "taken", Content: []byte("native"), + }) + require.NoError(t, err) + + _, err = m.CreateSecret(ctx, driver.SecretConfig{Name: "taken"}, []byte("portable")) + require.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) + assert.Contains(t, err.Error(), vaultID) + + // A free name is unaffected. + _, err = m.CreateSecret(ctx, driver.SecretConfig{Name: "free"}, []byte("portable")) + require.NoError(t, err) +} diff --git a/providers/oci/vault/race_test.go b/providers/oci/vault/race_test.go new file mode 100644 index 000000000..6a08975c2 --- /dev/null +++ b/providers/oci/vault/race_test.go @@ -0,0 +1,174 @@ +package vault + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/secrets/driver" +) + +// The stores hand back pointers, so writing a version mutates the very record +// a concurrent read is projecting. These tests fail under -race if the Mock's +// mutex is dropped from either path, and the create test fails outright: the +// duplicate-name check and the write are only atomic together. + +const raceGoroutines = 16 + +func TestConcurrentVersionWritesAndReads(t *testing.T) { + t.Parallel() + + m := newTestMock() + ctx := context.Background() + + // Created through the portable driver so PutSecretValue, which resolves a + // bare name inside the portable driver's vault, addresses this secret. + created, err := m.CreateSecret(ctx, driver.SecretConfig{Name: "hot"}, []byte("initial")) + require.NoError(t, err) + + s, err := m.GetOCISecret(created.ID) + require.NoError(t, err) + + var wg sync.WaitGroup + + for i := range raceGoroutines { + wg.Add(5) + + go func() { + defer wg.Done() + + if _, err := m.PutSecretValue(ctx, "hot", []byte(fmt.Sprintf("v%d", i))); err != nil { + t.Errorf("PutSecretValue: %v", err) + } + }() + + go func() { + defer wg.Done() + + if _, err := m.GetSecretBundle(s.ID, BundleSelector{}); err != nil { + t.Errorf("GetSecretBundle: %v", err) + } + }() + + go func() { + defer wg.Done() + + if _, err := m.ListOCISecretVersions(s.ID); err != nil { + t.Errorf("ListOCISecretVersions: %v", err) + } + }() + + go func() { + defer wg.Done() + + if _, err := m.ListOCISecrets(testCompartment, "", ""); err != nil { + t.Errorf("ListOCISecrets: %v", err) + } + }() + + go func() { + defer wg.Done() + + if _, err := m.CreateKeyVersion(s.KeyID); err != nil { + t.Errorf("CreateKeyVersion: %v", err) + } + }() + } + + wg.Wait() + + versions, err := m.ListOCISecretVersions(s.ID) + require.NoError(t, err) + assert.Len(t, versions, raceGoroutines+1) +} + +// Exactly one concurrent create of the same name may win, and the losers must +// all report AlreadyExists rather than overwriting each other. +func TestConcurrentCreateOfTheSameName(t *testing.T) { + t.Parallel() + + m := newTestMock() + ctx := context.Background() + + var ( + wg sync.WaitGroup + created atomic.Int64 + conflict atomic.Int64 + ) + + for range raceGoroutines { + wg.Add(1) + + go func() { + defer wg.Done() + + switch _, err := m.CreateSecret(ctx, driver.SecretConfig{Name: "contended"}, []byte("v")); { + case err == nil: + created.Add(1) + case cerrors.GetCode(err) == cerrors.AlreadyExists: + conflict.Add(1) + default: + t.Errorf("CreateSecret: %v", err) + } + }() + } + + wg.Wait() + + assert.Equal(t, int64(1), created.Load()) + assert.Equal(t, int64(raceGoroutines-1), conflict.Load()) + + // The default vault was minted exactly once despite the contention. + vaults, err := m.ListVaults(testCompartment) + require.NoError(t, err) + assert.Len(t, vaults, 1) +} + +// Scheduling and cancelling a deletion concurrently must leave the secret in +// one of the two states, never a torn one. +func TestConcurrentScheduleAndCancelDeletion(t *testing.T) { + t.Parallel() + + m := newTestMock() + s := newSecret(t, m, testCompartment, "contended-deletion", "v") + + var wg sync.WaitGroup + + for range raceGoroutines { + wg.Add(3) + + go func() { + defer wg.Done() + + //nolint:errcheck // either order is legal; the test is for the race detector. + m.ScheduleOCISecretDeletion(s.ID, "") + }() + + go func() { + defer wg.Done() + + //nolint:errcheck // either order is legal; the test is for the race detector. + m.CancelOCISecretDeletion(s.ID) + }() + + go func() { + defer wg.Done() + + if _, err := m.GetOCISecret(s.ID); err != nil { + t.Errorf("GetOCISecret: %v", err) + } + }() + } + + wg.Wait() + + got, err := m.GetOCISecret(s.ID) + require.NoError(t, err) + assert.Contains(t, []string{StateActive, StatePendingDeletion}, got.LifecycleState) +} diff --git a/providers/oci/vault/secret.go b/providers/oci/vault/secret.go new file mode 100644 index 000000000..122654ab6 --- /dev/null +++ b/providers/oci/vault/secret.go @@ -0,0 +1,461 @@ +package vault + +import ( + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/scope" +) + +// SecretSpec describes a secret to create in a vault. +type SecretSpec struct { + CompartmentID string + VaultID string + // KeyID is the master encryption key the secret is encrypted under. OCI + // requires one; the portable driver supplies the default vault's key. + KeyID string + Name string + Description string + Content []byte + // ContentName labels the first version; OCI's secretContent.name. + ContentName string + FreeformTags map[string]string +} + +// SecretUpdate carries the mutable fields of a secret. A nil field leaves the +// stored value alone. +type SecretUpdate struct { + Description *string + KeyID string + // CurrentVersionNumber promotes an existing version to CURRENT, which is + // how OCI finishes a rotation staged as PENDING. + CurrentVersionNumber *int64 + // Content adds a new version, entering Stage. + Content []byte + ContentName string + Stage string + // ContentGiven distinguishes an update carrying empty content from one + // carrying none, so an empty secret value is storable. + ContentGiven bool + FreeformTags map[string]string +} + +// SecretInfo describes a secret as the OCI Vault API reports it. +type SecretInfo struct { + ID string + CompartmentID string + VaultID string + KeyID string + Name string + Description string + LifecycleState string + CurrentVersionNumber int64 + TimeCreated string + TimeOfDeletion string + FreeformTags map[string]string +} + +type secretData struct { + ID string + VaultID string + KeyID string + Name string + Description string + LifecycleState string + TimeCreated string + TimeUpdated string + TimeOfDeletion string + CurrentVersion int64 + NextVersion int64 + Versions []*versionData + Scope scope.Scope + FreeformTags map[string]string +} + +// CreateOCISecret creates a secret and its first version in a vault. +func (m *Mock) CreateOCISecret(spec *SecretSpec) (*SecretInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if err := m.validateSecretSpecLocked(spec); err != nil { + return nil, err + } + + s := m.newSecretLocked(spec) + + info := toSecretInfo(s) + + return &info, nil +} + +// validateSecretSpecLocked checks a create against the vault, the key and the +// names already taken. +func (m *Mock) validateSecretSpecLocked(spec *SecretSpec) error { + if spec.Name == "" { + return cerrors.New(cerrors.InvalidArgument, "secretName is required") + } + + if spec.VaultID == "" { + return cerrors.New(cerrors.InvalidArgument, "vaultId is required") + } + + if spec.KeyID == "" { + return cerrors.New(cerrors.InvalidArgument, "keyId is required") + } + + if err := m.requireActiveVaultLocked(spec.VaultID); err != nil { + return err + } + + k, err := m.keyLocked(spec.KeyID) + if err != nil { + return err + } + + if k.VaultID != spec.VaultID { + return cerrors.Newf(cerrors.InvalidArgument, "key %s does not belong to vault %s", spec.KeyID, spec.VaultID) + } + + if _, ok := m.secretByNameLocked(spec.VaultID, spec.Name); ok { + return cerrors.Newf(cerrors.AlreadyExists, "secret %q already exists in vault %s", spec.Name, spec.VaultID) + } + + return nil +} + +// newSecretLocked stores a secret built from spec, with its first version +// staged CURRENT. +func (m *Mock) newSecretLocked(spec *SecretSpec) *secretData { + id := m.newOCID(typeSecret) + s := &secretData{ + ID: id, + VaultID: spec.VaultID, + KeyID: spec.KeyID, + Name: spec.Name, + Description: spec.Description, + LifecycleState: StateActive, + TimeCreated: m.now(), + TimeUpdated: m.now(), + NextVersion: 1, + Scope: scope.Scope{Compartment: m.compartmentOr(spec.CompartmentID)}, + FreeformTags: copyTags(spec.FreeformTags), + } + + m.secrets.Set(id, s) + m.addVersionLocked(s, spec.Content, spec.ContentName, StageCurrent) + + return s +} + +// GetOCISecret returns a secret by OCID. +func (m *Mock) GetOCISecret(id string) (*SecretInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + s, err := m.secretLocked(id) + if err != nil { + return nil, err + } + + info := toSecretInfo(s) + + return &info, nil +} + +// GetOCISecretByName returns a secret by vault and name, OCI's getByName +// action. It takes no compartmentId: the vault already names one. +func (m *Mock) GetOCISecretByName(vaultID, name string) (*SecretInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + s, err := m.secretByVaultAndNameLocked(vaultID, name) + if err != nil { + return nil, err + } + + info := toSecretInfo(s) + + return &info, nil +} + +// ListOCISecrets returns the secrets in a compartment, further filtered to one +// vault and one name when those are non-empty, ordered by OCID. +func (m *Mock) ListOCISecrets(compartmentID, vaultID, name string) ([]SecretInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + filter := scope.Scope{Compartment: compartmentID} + out := make([]SecretInfo, 0, m.secrets.Len()) + + for _, s := range m.secrets.SortedValues() { + if !s.Scope.Matches(filter) { + continue + } + + if (vaultID != "" && s.VaultID != vaultID) || (name != "" && s.Name != name) { + continue + } + + out = append(out, toSecretInfo(s)) + } + + return out, nil +} + +// UpdateOCISecret applies a secret update: a new description or tag set, a +// re-key, a new version, or the promotion of an existing version to CURRENT. +func (m *Mock) UpdateOCISecret(id string, upd *SecretUpdate) (*SecretInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + s, err := m.secretLocked(id) + if err != nil { + return nil, err + } + + if s.LifecycleState != StateActive { + return nil, cerrors.Newf(cerrors.FailedPrecondition, "secret %s is %s", id, s.LifecycleState) + } + + if err := m.rekeyLocked(s, upd.KeyID); err != nil { + return nil, err + } + + if err := m.applySecretVersionUpdateLocked(s, upd); err != nil { + return nil, err + } + + if upd.Description != nil { + s.Description = *upd.Description + } + + if upd.FreeformTags != nil { + s.FreeformTags = copyTags(upd.FreeformTags) + } + + s.TimeUpdated = m.now() + + info := toSecretInfo(s) + + return &info, nil +} + +// applySecretVersionUpdateLocked adds the version an update carries and +// promotes the one it names. +func (m *Mock) applySecretVersionUpdateLocked(s *secretData, upd *SecretUpdate) error { + if upd.ContentGiven { + stage, err := newVersionStage(upd.Stage) + if err != nil { + return err + } + + if versionNameTaken(s, upd.ContentName) { + return cerrors.Newf(cerrors.AlreadyExists, + "secret %s already has a version named %q", s.ID, upd.ContentName) + } + + m.addVersionLocked(s, upd.Content, upd.ContentName, stage) + } + + if upd.CurrentVersionNumber != nil { + return promoteVersion(s, *upd.CurrentVersionNumber) + } + + return nil +} + +// rekeyLocked points a secret at another master encryption key in its vault. +func (m *Mock) rekeyLocked(s *secretData, keyID string) error { + if keyID == "" || keyID == s.KeyID { + return nil + } + + k, err := m.keyLocked(keyID) + if err != nil { + return err + } + + if k.VaultID != s.VaultID { + return cerrors.Newf(cerrors.InvalidArgument, "key %s does not belong to vault %s", keyID, s.VaultID) + } + + s.KeyID = keyID + + return nil +} + +// ScheduleOCISecretDeletion marks a secret for deletion at the given time, +// which must fall between 1 and 30 days out. An empty time takes the far end, +// as real OCI does. Nothing reaps the secret: it stays PENDING_DELETION, and +// keeps its OCID and versions, until the deletion is canceled. +func (m *Mock) ScheduleOCISecretDeletion(id, at string) (*SecretInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + s, err := m.secretLocked(id) + if err != nil { + return nil, err + } + + when, err := m.deletionTime(at, minSecretDeletionDays) + if err != nil { + return nil, err + } + + if err := scheduleSecret(s, when); err != nil { + return nil, err + } + + info := toSecretInfo(s) + + return &info, nil +} + +// scheduleSecret moves a secret into PENDING_DELETION. +func scheduleSecret(s *secretData, when string) error { + if s.LifecycleState == StatePendingDeletion { + return cerrors.Newf(cerrors.FailedPrecondition, "secret %s is already scheduled for deletion", s.ID) + } + + s.LifecycleState = StatePendingDeletion + s.TimeOfDeletion = when + + return nil +} + +// CancelOCISecretDeletion returns a secret scheduled for deletion to ACTIVE. +// It fails if another secret in the same vault has taken its name in the +// meantime. +func (m *Mock) CancelOCISecretDeletion(id string) (*SecretInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + s, err := m.secretLocked(id) + if err != nil { + return nil, err + } + + if s.LifecycleState != StatePendingDeletion { + return nil, cerrors.Newf(cerrors.FailedPrecondition, "secret %s is not scheduled for deletion", id) + } + + if other, ok := m.secretByNameLocked(s.VaultID, s.Name); ok && other.ID != s.ID { + return nil, cerrors.Newf(cerrors.AlreadyExists, + "secret %q was recreated as %s while this one was pending deletion", s.Name, other.ID) + } + + s.LifecycleState = StateActive + s.TimeOfDeletion = "" + + info := toSecretInfo(s) + + return &info, nil +} + +// ChangeSecretCompartment moves a secret to another compartment. +func (m *Mock) ChangeSecretCompartment(id, compartmentID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if compartmentID == "" { + return cerrors.New(cerrors.InvalidArgument, "compartmentId is required") + } + + s, err := m.secretLocked(id) + if err != nil { + return err + } + + s.Scope = scope.Scope{Compartment: compartmentID} + + return nil +} + +// SecretCompartment returns the compartment a secret lives in, for the work +// request the wire layer records. +func (m *Mock) SecretCompartment(id string) string { + m.mu.RLock() + defer m.mu.RUnlock() + + s, ok := m.secrets.Get(id) + if !ok { + return "" + } + + return s.Scope.Compartment +} + +// secretLocked reads a secret, reporting OCI's not-found for an unknown OCID. +func (m *Mock) secretLocked(id string) (*secretData, error) { + s, ok := m.secrets.Get(id) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "secret %s not found", id) + } + + return s, nil +} + +// secretByNameLocked finds a live secret by name within one vault. OCI scopes +// secret names to the vault, so the same name in another vault is a different +// secret. A secret pending deletion releases its name, so the portable driver +// can delete and recreate one; the pending secret stays reachable by OCID +// until its deletion is canceled. +func (m *Mock) secretByNameLocked(vaultID, name string) (*secretData, bool) { + for _, s := range m.secrets.SortedValues() { + if s.VaultID == vaultID && s.Name == name && s.LifecycleState == StateActive { + return s, true + } + } + + return nil, false +} + +// liveSecretByNameLocked finds the first live secret of that name in any vault. +// The portable create uses it to refuse a name it could not then read back. +func (m *Mock) liveSecretByNameLocked(name string) (*secretData, bool) { + for _, s := range m.secrets.SortedValues() { + if s.Name == name && s.LifecycleState == StateActive { + return s, true + } + } + + return nil, false +} + +// secretByVaultAndNameLocked resolves OCI's getByName addressing. An active +// secret wins over one pending deletion that still holds the same name. +func (m *Mock) secretByVaultAndNameLocked(vaultID, name string) (*secretData, error) { + if vaultID == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "vaultId is required") + } + + if name == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "secretName is required") + } + + if s, ok := m.secretByNameLocked(vaultID, name); ok { + return s, nil + } + + for _, s := range m.secrets.SortedValues() { + if s.VaultID == vaultID && s.Name == name { + return s, nil + } + } + + return nil, cerrors.Newf(cerrors.NotFound, "secret %q not found in vault %s", name, vaultID) +} + +func toSecretInfo(s *secretData) SecretInfo { + return SecretInfo{ + ID: s.ID, + CompartmentID: s.Scope.Compartment, + VaultID: s.VaultID, + KeyID: s.KeyID, + Name: s.Name, + Description: s.Description, + LifecycleState: s.LifecycleState, + CurrentVersionNumber: s.CurrentVersion, + TimeCreated: s.TimeCreated, + TimeOfDeletion: s.TimeOfDeletion, + FreeformTags: copyTags(s.FreeformTags), + } +} diff --git a/providers/oci/vault/secret_test.go b/providers/oci/vault/secret_test.go new file mode 100644 index 000000000..56f043939 --- /dev/null +++ b/providers/oci/vault/secret_test.go @@ -0,0 +1,346 @@ +package vault + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +func TestCreateOCISecret(t *testing.T) { + m := newTestMock() + vaultID, keyID := newVaultAndKey(t, m, testCompartment) + + info, err := m.CreateOCISecret(&SecretSpec{ + CompartmentID: testCompartment, + VaultID: vaultID, + KeyID: keyID, + Name: "db-password", + Description: "the database password", + Content: []byte("hunter2"), + ContentName: "v1", + FreeformTags: map[string]string{"env": "test"}, + }) + require.NoError(t, err) + + assert.True(t, strings.HasPrefix(info.ID, "ocid1.vaultsecret.oc1.iad."), "got %q", info.ID) + assert.Equal(t, "db-password", info.Name) + assert.Equal(t, StateActive, info.LifecycleState) + assert.Equal(t, int64(1), info.CurrentVersionNumber) + assert.Equal(t, testCompartment, info.CompartmentID) + assert.Equal(t, "test", info.FreeformTags["env"]) +} + +func TestCreateOCISecretRejections(t *testing.T) { + m := newTestMock() + vaultID, keyID := newVaultAndKey(t, m, testCompartment) + otherVault, otherKey := newVaultAndKey(t, m, otherCompart) + + base := &SecretSpec{CompartmentID: testCompartment, VaultID: vaultID, KeyID: keyID, Name: "s"} + + _, err := m.CreateOCISecret(base) + require.NoError(t, err) + + tests := []struct { + name string + spec *SecretSpec + expect cerrors.Code + }{ + {name: "duplicate name", spec: base, expect: cerrors.AlreadyExists}, + { + name: "no name", + spec: &SecretSpec{CompartmentID: testCompartment, VaultID: vaultID, KeyID: keyID}, + expect: cerrors.InvalidArgument, + }, + { + name: "no vault", + spec: &SecretSpec{CompartmentID: testCompartment, KeyID: keyID, Name: "x"}, + expect: cerrors.InvalidArgument, + }, + { + name: "no key", + spec: &SecretSpec{CompartmentID: testCompartment, VaultID: vaultID, Name: "x"}, + expect: cerrors.InvalidArgument, + }, + { + name: "unknown vault", + spec: &SecretSpec{CompartmentID: testCompartment, VaultID: "ocid1.vault.oc1.iad.x", KeyID: keyID, Name: "x"}, + expect: cerrors.NotFound, + }, + { + name: "unknown key", + spec: &SecretSpec{CompartmentID: testCompartment, VaultID: vaultID, KeyID: "ocid1.key.oc1.iad.x", Name: "x"}, + expect: cerrors.NotFound, + }, + { + name: "key from another vault", + spec: &SecretSpec{CompartmentID: testCompartment, VaultID: vaultID, KeyID: otherKey, Name: "x"}, + expect: cerrors.InvalidArgument, + }, + { + name: "vault from another compartment still resolves by OCID", + spec: &SecretSpec{CompartmentID: otherCompart, VaultID: otherVault, KeyID: otherKey, Name: "x"}, + expect: cerrors.OK, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := m.CreateOCISecret(tc.spec) + if tc.expect == cerrors.OK { + require.NoError(t, err) + return + } + + require.Error(t, err) + assert.Equal(t, tc.expect, cerrors.GetCode(err)) + }) + } +} + +func TestListOCISecretsFiltersByCompartmentVaultAndName(t *testing.T) { + m := newTestMock() + mine := newSecret(t, m, testCompartment, "mine", "a") + newSecret(t, m, otherCompart, "theirs", "b") + + got, err := m.ListOCISecrets(testCompartment, "", "") + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "mine", got[0].Name) + + byVault, err := m.ListOCISecrets(testCompartment, mine.VaultID, "") + require.NoError(t, err) + assert.Len(t, byVault, 1) + + byName, err := m.ListOCISecrets(testCompartment, "", "mine") + require.NoError(t, err) + assert.Len(t, byName, 1) + + missing, err := m.ListOCISecrets(testCompartment, "", "nope") + require.NoError(t, err) + assert.Empty(t, missing) +} + +func TestGetOCISecretByName(t *testing.T) { + m := newTestMock() + s := newSecret(t, m, testCompartment, "named", "v") + + got, err := m.GetOCISecretByName(s.VaultID, "named") + require.NoError(t, err) + assert.Equal(t, s.ID, got.ID) + + _, err = m.GetOCISecretByName(s.VaultID, "other") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + _, err = m.GetOCISecretByName("", "named") + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + + _, err = m.GetOCISecretByName(s.VaultID, "") + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + + _, err = m.GetOCISecret("ocid1.vaultsecret.oc1.iad.x") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestSecretScheduledDeletionAndCancellation(t *testing.T) { + m := newTestMock() + s := newSecret(t, m, testCompartment, "doomed", "v") + + scheduled, err := m.ScheduleOCISecretDeletion(s.ID, "") + require.NoError(t, err) + assert.Equal(t, StatePendingDeletion, scheduled.LifecycleState) + assert.Equal(t, "2026-01-31T00:00:00Z", scheduled.TimeOfDeletion) + + // Still addressable by OCID, and still listed by the OCI-shaped surface. + listed, err := m.ListOCISecrets(testCompartment, "", "") + require.NoError(t, err) + require.Len(t, listed, 1) + assert.Equal(t, StatePendingDeletion, listed[0].LifecycleState) + + _, err = m.ScheduleOCISecretDeletion(s.ID, "") + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + // A secret pending deletion cannot be updated. + _, err = m.UpdateOCISecret(s.ID, &SecretUpdate{}) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + restored, err := m.CancelOCISecretDeletion(s.ID) + require.NoError(t, err) + assert.Equal(t, StateActive, restored.LifecycleState) + assert.Empty(t, restored.TimeOfDeletion) + + _, err = m.CancelOCISecretDeletion(s.ID) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) +} + +// A secret pending deletion releases its name, so the same name can be taken +// again; cancelling then fails rather than producing two live secrets alike. +func TestCancelDeletionRefusesAReusedName(t *testing.T) { + m := newTestMock() + first := newSecret(t, m, testCompartment, "reused", "a") + + _, err := m.ScheduleOCISecretDeletion(first.ID, "") + require.NoError(t, err) + + second, err := m.CreateOCISecret(&SecretSpec{ + CompartmentID: testCompartment, + VaultID: first.VaultID, + KeyID: first.KeyID, + Name: "reused", + Content: []byte("b"), + }) + require.NoError(t, err) + assert.NotEqual(t, first.ID, second.ID) + + _, err = m.CancelOCISecretDeletion(first.ID) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) +} + +func TestUpdateOCISecret(t *testing.T) { + m := newTestMock() + s := newSecret(t, m, testCompartment, "updatable", "a") + + desc := "now described" + + got, err := m.UpdateOCISecret(s.ID, &SecretUpdate{ + Description: &desc, + FreeformTags: map[string]string{"k": "v"}, + }) + require.NoError(t, err) + assert.Equal(t, "now described", got.Description) + assert.Equal(t, "v", got.FreeformTags["k"]) + assert.Equal(t, int64(1), got.CurrentVersionNumber) + + _, err = m.UpdateOCISecret("ocid1.vaultsecret.oc1.iad.x", &SecretUpdate{}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestUpdateOCISecretRekeys(t *testing.T) { + m := newTestMock() + s := newSecret(t, m, testCompartment, "rekey", "a") + + second, err := m.CreateKey(&KeySpec{ + CompartmentID: testCompartment, + VaultID: s.VaultID, + DisplayName: "k2", + Shape: KeyShape{Algorithm: AlgorithmAES, Length: 32}, + }) + require.NoError(t, err) + + got, err := m.UpdateOCISecret(s.ID, &SecretUpdate{KeyID: second.ID}) + require.NoError(t, err) + assert.Equal(t, second.ID, got.KeyID) + + _, foreignKey := newVaultAndKey(t, m, testCompartment) + + _, err = m.UpdateOCISecret(s.ID, &SecretUpdate{KeyID: foreignKey}) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + + _, err = m.UpdateOCISecret(s.ID, &SecretUpdate{KeyID: "ocid1.key.oc1.iad.x"}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestChangeSecretCompartment(t *testing.T) { + m := newTestMock() + s := newSecret(t, m, testCompartment, "movable", "a") + + require.NoError(t, m.ChangeSecretCompartment(s.ID, otherCompart)) + assert.Equal(t, otherCompart, m.SecretCompartment(s.ID)) + + left, err := m.ListOCISecrets(testCompartment, "", "") + require.NoError(t, err) + assert.Empty(t, left) + + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(m.ChangeSecretCompartment(s.ID, ""))) + assert.Equal(t, cerrors.NotFound, + cerrors.GetCode(m.ChangeSecretCompartment("ocid1.vaultsecret.oc1.iad.x", otherCompart))) + assert.Empty(t, m.SecretCompartment("ocid1.vaultsecret.oc1.iad.x")) +} + +// OCI scopes secret names to the vault, so the same name in two vaults is two +// secrets rather than a conflict. +func TestSecretNamesAreUniquePerVault(t *testing.T) { + m := newTestMock() + vaultA, keyA := newVaultAndKey(t, m, testCompartment) + vaultB, keyB := newVaultAndKey(t, m, testCompartment) + + first, err := m.CreateOCISecret(&SecretSpec{ + CompartmentID: testCompartment, + VaultID: vaultA, KeyID: keyA, + Name: "db-password", Content: []byte("a"), + }) + require.NoError(t, err) + + second, err := m.CreateOCISecret(&SecretSpec{ + CompartmentID: testCompartment, + VaultID: vaultB, KeyID: keyB, + Name: "db-password", Content: []byte("b"), + }) + require.NoError(t, err) + + assert.NotEqual(t, first.ID, second.ID) + assert.Equal(t, vaultA, first.VaultID) + assert.Equal(t, vaultB, second.VaultID) + + // Each vault's getByName resolves to its own secret. + got, err := m.GetOCISecretByName(vaultA, "db-password") + require.NoError(t, err) + assert.Equal(t, first.ID, got.ID) + + got, err = m.GetOCISecretByName(vaultB, "db-password") + require.NoError(t, err) + assert.Equal(t, second.ID, got.ID) + + // Within one vault the name is still taken. + _, err = m.CreateOCISecret(&SecretSpec{ + CompartmentID: testCompartment, + VaultID: vaultA, KeyID: keyA, + Name: "db-password", Content: []byte("c"), + }) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) +} + +// An unrelated vault reusing the name must not block a restore. +func TestCancelDeletionAllowsTheSameNameInAnotherVault(t *testing.T) { + m := newTestMock() + first := newSecret(t, m, testCompartment, "shared", "a") + vaultB, keyB := newVaultAndKey(t, m, testCompartment) + + _, err := m.ScheduleOCISecretDeletion(first.ID, "") + require.NoError(t, err) + + _, err = m.CreateOCISecret(&SecretSpec{ + CompartmentID: testCompartment, + VaultID: vaultB, KeyID: keyB, + Name: "shared", Content: []byte("b"), + }) + require.NoError(t, err) + + restored, err := m.CancelOCISecretDeletion(first.ID) + require.NoError(t, err) + assert.Equal(t, StateActive, restored.LifecycleState) +} + +// A pending-deletion secret keeps its name until the deletion is cancelled, so +// getByName must not return it in preference to the live secret beside it. +func TestGetOCISecretByNamePrefersTheActiveSecret(t *testing.T) { + m := newTestMock() + first := newSecret(t, m, testCompartment, "reused", "a") + + _, err := m.ScheduleOCISecretDeletion(first.ID, "") + require.NoError(t, err) + + second, err := m.CreateOCISecret(&SecretSpec{ + CompartmentID: testCompartment, + VaultID: first.VaultID, KeyID: first.KeyID, + Name: "reused", Content: []byte("b"), + }) + require.NoError(t, err) + + got, err := m.GetOCISecretByName(first.VaultID, "reused") + require.NoError(t, err) + assert.Equal(t, second.ID, got.ID) +} diff --git a/providers/oci/vault/snapshot.go b/providers/oci/vault/snapshot.go new file mode 100644 index 000000000..262ef3930 --- /dev/null +++ b/providers/oci/vault/snapshot.go @@ -0,0 +1,103 @@ +package vault + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/stackshy/cloudemu/v2/internal/snapshot" +) + +var _ snapshot.Snapshottable = (*Mock)(nil) + +// vaultSnapshot is the full serialized state of the OCI Vault mock. Every +// memstore store is dumped keyed by its resource OCID, so the cross-references +// that only exist as id strings — a key's VaultID, a secret's VaultID and +// KeyID, a key version's KeyID — still resolve after a restore. A secret's +// versions hang off the secret itself and travel with it, stages and all. +// +// The portable driver's default vault and key are captured alongside the +// stores: without them a restored mock would mint a second default vault on +// the next portable CreateSecret and orphan every secret it had restored. +// +// Every Vault value type has fully exported fields, so all four stores +// round-trip through the generic memstore helper; the mutex and *config.Options +// are not serialized. +type vaultSnapshot struct { + Vaults json.RawMessage `json:"vaults,omitempty"` + Keys json.RawMessage `json:"keys,omitempty"` + KeyVersions json.RawMessage `json:"keyVersions,omitempty"` + Secrets json.RawMessage `json:"secrets,omitempty"` + + DefaultVaultID string `json:"defaultVaultId,omitempty"` + DefaultKeyID string `json:"defaultKeyId,omitempty"` +} + +// Snapshot captures the mock's entire state as JSON. includeAssets is unused — +// Vault holds no bulk object bodies; a secret's content is part of its version. +func (m *Mock) Snapshot(_ context.Context, _ bool) (json.RawMessage, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + snap := vaultSnapshot{ + DefaultVaultID: m.defaultVaultID, + DefaultKeyID: m.defaultKeyID, + } + + for _, d := range m.snapshotDumps(&snap) { + b, err := d.fn() + if err != nil { + return nil, fmt.Errorf("vault: snapshot store: %w", err) + } + + *d.dst = b + } + + return json.Marshal(snap) +} + +// Restore rebuilds the mock's state under the original identities: every OCID, +// the id-string cross-references between vaults, keys and secrets, each +// secret's version stages, and any scheduled deletion still pending. +func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { + var snap vaultSnapshot + if err := json.Unmarshal(data, &snap); err != nil { + return fmt.Errorf("vault: parse snapshot: %w", err) + } + + m.mu.Lock() + defer m.mu.Unlock() + + for _, d := range m.snapshotDumps(&snap) { + if len(*d.dst) == 0 { + continue + } + + if err := d.load(*d.dst); err != nil { + return fmt.Errorf("vault: restore store: %w", err) + } + } + + m.defaultVaultID = snap.DefaultVaultID + m.defaultKeyID = snap.DefaultKeyID + + return nil +} + +// storeDump pairs a snapshot field with its store's dump and load functions, so +// Snapshot and Restore share one table and cannot drift apart. +type storeDump struct { + dst *json.RawMessage + fn func() ([]byte, error) + load func([]byte) error +} + +// snapshotDumps lists every store alongside the snapshot field it maps to. +func (m *Mock) snapshotDumps(snap *vaultSnapshot) []storeDump { + return []storeDump{ + {&snap.Vaults, m.vaults.Snapshot, m.vaults.LoadSnapshot}, + {&snap.Keys, m.keys.Snapshot, m.keys.LoadSnapshot}, + {&snap.KeyVersions, m.keyVersions.Snapshot, m.keyVersions.LoadSnapshot}, + {&snap.Secrets, m.secrets.Snapshot, m.secrets.LoadSnapshot}, + } +} diff --git a/providers/oci/vault/snapshot_test.go b/providers/oci/vault/snapshot_test.go new file mode 100644 index 000000000..5be2eae38 --- /dev/null +++ b/providers/oci/vault/snapshot_test.go @@ -0,0 +1,256 @@ +package vault + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/services/secrets/driver" +) + +// TestSnapshotRestoreRoundTrip seeds every store — a vault, a key with a +// rotated second version, and a secret with two versions — snapshots, restores +// into a fresh mock and asserts each resource comes back under its original +// OCID with its cross-references, version stages and tags intact. +func TestSnapshotRestoreRoundTrip(t *testing.T) { + ctx := t.Context() + src := newTestMock() + + v, err := src.CreateVault(&VaultSpec{ + CompartmentID: testCompartment, + DisplayName: "prod", + FreeformTags: map[string]string{"env": "prod"}, + }) + require.NoError(t, err) + + k, err := src.CreateKey(&KeySpec{ + CompartmentID: testCompartment, + VaultID: v.ID, + DisplayName: "master", + Shape: KeyShape{Algorithm: AlgorithmAES, Length: 32}, + FreeformTags: map[string]string{"owner": "platform"}, + }) + require.NoError(t, err) + + // Rotate, so the key-versions store holds more than the create-time one. + rotated, err := src.CreateKeyVersion(k.ID) + require.NoError(t, err) + + s, err := src.CreateOCISecret(&SecretSpec{ + CompartmentID: testCompartment, + VaultID: v.ID, + KeyID: k.ID, + Name: "db-password", + Description: "the database password", + Content: []byte("v1-value"), + ContentName: "one", + FreeformTags: map[string]string{"tier": "db"}, + }) + require.NoError(t, err) + + // A second version, so the restored secret must carry stages, not just one + // blob: version 2 becomes CURRENT and version 1 becomes PREVIOUS. + _, err = src.UpdateOCISecret(s.ID, &SecretUpdate{ + Content: []byte("v2-value"), ContentName: "two", ContentGiven: true, + }) + require.NoError(t, err) + + data, err := src.Snapshot(ctx, false) + require.NoError(t, err) + + dst := newTestMock() + require.NoError(t, dst.Restore(ctx, data)) + + // The vault store. + gotVault, err := dst.GetVault(v.ID) + require.NoError(t, err) + assert.Equal(t, "prod", gotVault.DisplayName) + assert.Equal(t, StateActive, gotVault.LifecycleState) + assert.Equal(t, "prod", gotVault.FreeformTags["env"]) + + // The key store, still pointing at its vault. + gotKey, err := dst.GetKey(k.ID) + require.NoError(t, err) + assert.Equal(t, v.ID, gotKey.VaultID) + assert.Equal(t, "master", gotKey.DisplayName) + assert.Equal(t, KeyShape{Algorithm: AlgorithmAES, Length: 32}, gotKey.Shape) + assert.Equal(t, "platform", gotKey.FreeformTags["owner"]) + assert.Equal(t, rotated.ID, gotKey.CurrentKeyVersion) + + // The key-version store: both versions survive, keyed to their key. + versions, err := dst.ListKeyVersions(k.ID) + require.NoError(t, err) + require.Len(t, versions, 2) + + for _, kv := range versions { + assert.Equal(t, k.ID, kv.KeyID) + assert.Equal(t, v.ID, kv.VaultID) + } + + gotKV, err := dst.GetKeyVersion(k.ID, rotated.ID) + require.NoError(t, err) + assert.Equal(t, rotated.ID, gotKV.ID) + + // The secret store, still pointing at its vault and key. + gotSecret, err := dst.GetOCISecret(s.ID) + require.NoError(t, err) + assert.Equal(t, v.ID, gotSecret.VaultID) + assert.Equal(t, k.ID, gotSecret.KeyID) + assert.Equal(t, "db-password", gotSecret.Name) + assert.Equal(t, "the database password", gotSecret.Description) + assert.Equal(t, int64(2), gotSecret.CurrentVersionNumber) + assert.Equal(t, "db", gotSecret.FreeformTags["tier"]) + + // getByName still resolves inside the restored vault. + byName, err := dst.GetOCISecretByName(v.ID, "db-password") + require.NoError(t, err) + assert.Equal(t, s.ID, byName.ID) + + // Secret versions and their stages survive. + secretVersions, err := dst.ListOCISecretVersions(s.ID) + require.NoError(t, err) + require.Len(t, secretVersions, 2) + assert.Equal(t, "one", secretVersions[0].Name) + assert.Contains(t, secretVersions[0].Stages, StagePrevious) + assert.Equal(t, "two", secretVersions[1].Name) + assert.Contains(t, secretVersions[1].Stages, StageCurrent) + + // The values themselves round-trip through the data plane. + bundle, err := dst.GetSecretBundle(s.ID, BundleSelector{}) + require.NoError(t, err) + assert.Equal(t, []byte("v2-value"), bundle.Content) + + one := int64(1) + + bundle, err = dst.GetSecretBundle(s.ID, BundleSelector{VersionNumber: &one}) + require.NoError(t, err) + assert.Equal(t, []byte("v1-value"), bundle.Content) +} + +// A deletion scheduled before the snapshot is still pending after the restore, +// with its deletion time, and can still be cancelled. +func TestSnapshotPreservesScheduledDeletion(t *testing.T) { + ctx := t.Context() + src := newTestMock() + + s := newSecret(t, src, testCompartment, "doomed", "v") + + scheduled, err := src.ScheduleOCISecretDeletion(s.ID, "") + require.NoError(t, err) + require.Equal(t, StatePendingDeletion, scheduled.LifecycleState) + + // A vault-level deletion too, so both lifecycles are covered. + otherVault, err := src.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "going"}) + require.NoError(t, err) + + _, err = src.ScheduleVaultDeletion(otherVault.ID, "") + require.NoError(t, err) + + data, err := src.Snapshot(ctx, false) + require.NoError(t, err) + + dst := newTestMock() + require.NoError(t, dst.Restore(ctx, data)) + + got, err := dst.GetOCISecret(s.ID) + require.NoError(t, err) + assert.Equal(t, StatePendingDeletion, got.LifecycleState) + assert.Equal(t, scheduled.TimeOfDeletion, got.TimeOfDeletion) + + gotVault, err := dst.GetVault(otherVault.ID) + require.NoError(t, err) + assert.Equal(t, StatePendingDeletion, gotVault.LifecycleState) + + // The restored secret is still restorable, which proves the pending state + // came back as state rather than as a frozen projection. + restored, err := dst.CancelOCISecretDeletion(s.ID) + require.NoError(t, err) + assert.Equal(t, StateActive, restored.LifecycleState) +} + +// The portable driver's default vault and key must survive, or a restored mock +// would mint a second default vault and orphan the secrets it just restored. +func TestSnapshotPreservesThePortableDefaultVault(t *testing.T) { + ctx := t.Context() + src := newTestMock() + + created, err := src.CreateSecret(ctx, driver.SecretConfig{Name: "api-key"}, []byte("secret")) + require.NoError(t, err) + + data, err := src.Snapshot(ctx, false) + require.NoError(t, err) + + dst := newTestMock() + require.NoError(t, dst.Restore(ctx, data)) + + // Readable through the portable surface, under its original OCID. + got, err := dst.GetSecret(ctx, "api-key") + require.NoError(t, err) + assert.Equal(t, created.ID, got.ID) + + value, err := dst.GetSecretValue(ctx, "api-key", "") + require.NoError(t, err) + assert.Equal(t, []byte("secret"), value.Value) + + // A further portable create reuses the restored vault rather than minting + // a second one. + _, err = dst.CreateSecret(ctx, driver.SecretConfig{Name: "another"}, []byte("v")) + require.NoError(t, err) + + vaults, err := dst.ListVaults(testCompartment) + require.NoError(t, err) + assert.Len(t, vaults, 1) +} + +// Restoring must not alias the source mock's values: mutating the restored copy +// leaves the original untouched. +func TestSnapshotRestoreDeepCopies(t *testing.T) { + ctx := t.Context() + src := newTestMock() + + s := newSecret(t, src, testCompartment, "shared", "original") + + data, err := src.Snapshot(ctx, false) + require.NoError(t, err) + + dst := newTestMock() + require.NoError(t, dst.Restore(ctx, data)) + + _, err = dst.UpdateOCISecret(s.ID, &SecretUpdate{ + Content: []byte("changed"), ContentGiven: true, + FreeformTags: map[string]string{"touched": "yes"}, + }) + require.NoError(t, err) + + // The source still has one version, its original value and no new tag. + srcVersions, err := src.ListOCISecretVersions(s.ID) + require.NoError(t, err) + assert.Len(t, srcVersions, 1) + + bundle, err := src.GetSecretBundle(s.ID, BundleSelector{}) + require.NoError(t, err) + assert.Equal(t, []byte("original"), bundle.Content) + + srcSecret, err := src.GetOCISecret(s.ID) + require.NoError(t, err) + assert.NotContains(t, srcSecret.FreeformTags, "touched") +} + +// An empty snapshot restores cleanly, and malformed JSON is an error rather +// than a panic or a half-loaded store. +func TestSnapshotRestoreEdgeCases(t *testing.T) { + ctx := t.Context() + + empty, err := newTestMock().Snapshot(ctx, false) + require.NoError(t, err) + + dst := newTestMock() + require.NoError(t, dst.Restore(ctx, empty)) + + vaults, err := dst.ListVaults(testCompartment) + require.NoError(t, err) + assert.Empty(t, vaults) + + require.Error(t, dst.Restore(ctx, []byte("not-json"))) +} diff --git a/providers/oci/vault/vault.go b/providers/oci/vault/vault.go new file mode 100644 index 000000000..88797a653 --- /dev/null +++ b/providers/oci/vault/vault.go @@ -0,0 +1,439 @@ +// Package vault provides an in-memory mock implementation of OCI Vault. It +// implements the portable secrets driver, and carries alongside it the KMS +// surface the portable interface has no room for: vaults, master encryption +// keys, key versions, and OCI's scheduled deletion. +package vault + +import ( + "sync" + "time" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/internal/memstore" + "github.com/stackshy/cloudemu/v2/services/scope" + "github.com/stackshy/cloudemu/v2/services/secrets/driver" +) + +// Compile-time check that Mock implements the portable driver. The OCI-shaped +// capabilities live in server/oci/vault and are checked there. +var _ driver.Secrets = (*Mock)(nil) + +const timeFormat = time.RFC3339 + +// day is the unit OCI states its deletion windows in. +const day = 24 * time.Hour + +// Lifecycle states an OCI Vault resource reports. The KMS surface and the +// secret surface do not share a live state: per the SDK's +// KeyLifecycleStateEnum a key or key version is ENABLED, never ACTIVE, while +// VaultLifecycleStateEnum and SecretLifecycleStateEnum both use ACTIVE. +// terraform-provider-oci's oci_kms_key waits on ENABLED, so the distinction is +// load-bearing. The rest of KeyLifecycleStateEnum — ENABLING, DISABLING, +// DISABLED — arrives with the enableKey/disableKey operations, which this +// handler does not serve. +const ( + StateActive = "ACTIVE" + StateEnabled = "ENABLED" + StatePendingDeletion = "PENDING_DELETION" +) + +// Vault types. +const ( + VaultTypeDefault = "DEFAULT" + VaultTypeVirtualPrivate = "VIRTUAL_PRIVATE" +) + +// OCID resource type segments. +const ( + typeVault = "vault" + typeKey = "key" + typeKeyVersion = "keyversion" + typeSecret = "vaultsecret" +) + +// Scheduled-deletion windows, in days. Real OCI refuses a time outside them +// and defaults to the far end when the caller names none. +const ( + minSecretDeletionDays = 1 + minVaultDeletionDays = 7 + minKeyDeletionDays = 7 + maxDeletionDays = 30 +) + +// Names the vault and key that the portable driver creates its secrets in. +const ( + defaultVaultName = "cloudemu-default-vault" + defaultKeyName = "cloudemu-default-key" +) + +// VaultSpec describes a vault to create. +// +//nolint:revive // Spec alone would not distinguish it from KeySpec and SecretSpec. +type VaultSpec struct { + CompartmentID string + DisplayName string + // VaultType is DEFAULT or VIRTUAL_PRIVATE; empty means DEFAULT. + VaultType string + FreeformTags map[string]string +} + +// Update carries the mutable fields of a vault or a key. A nil field leaves +// the stored value alone. +type Update struct { + DisplayName *string + FreeformTags map[string]string +} + +// VaultInfo describes a vault. +// +//nolint:revive // Info alone would not distinguish it from KeyInfo and SecretInfo. +type VaultInfo struct { + ID string + CompartmentID string + DisplayName string + VaultType string + CryptoEndpoint string + ManagementEndpoint string + LifecycleState string + TimeCreated string + TimeOfDeletion string + FreeformTags map[string]string +} + +type vaultData struct { + ID string + DisplayName string + VaultType string + LifecycleState string + TimeCreated string + TimeOfDeletion string + Scope scope.Scope + FreeformTags map[string]string +} + +// Mock is an in-memory mock implementation of the OCI Vault service. +type Mock struct { + // mu guards the fields of stored values and spans the reads and writes a + // single operation makes across stores. Each store locks its own map, but + // the pointers it hands back are mutated in place, and checks such as + // "does this secret's vault still exist" read one store before writing + // another. + mu sync.RWMutex + + vaults *memstore.Store[*vaultData] + keys *memstore.Store[*keyData] + keyVersions *memstore.Store[*keyVersionData] + secrets *memstore.Store[*secretData] + + // The vault and key the portable driver puts its secrets in, created on + // the first portable CreateSecret. OCI has no secret outside a vault. + defaultVaultID string + defaultKeyID string + + opts *config.Options +} + +// New creates a new OCI Vault mock. +func New(opts *config.Options) *Mock { + return &Mock{ + vaults: memstore.New[*vaultData](), + keys: memstore.New[*keyData](), + keyVersions: memstore.New[*keyVersionData](), + secrets: memstore.New[*secretData](), + opts: opts, + } +} + +// newOCID mints an OCID for the given resource type in the configured realm +// and region. +func (m *Mock) newOCID(resourceType string) string { + return idgen.OCID(resourceType, m.opts.Realm, m.opts.OCIRegion()) +} + +// now returns the current time in OCI's timestamp format. +func (m *Mock) now() string { + return m.opts.Clock.Now().UTC().Format(timeFormat) +} + +// CreateVault creates a vault. Real OCI runs this asynchronously; CloudEmu +// completes it before returning and the wire layer records the work request. +func (m *Mock) CreateVault(spec *VaultSpec) (*VaultInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if spec.DisplayName == "" { + return nil, cerrors.New(cerrors.InvalidArgument, "displayName is required") + } + + vaultType := spec.VaultType + if vaultType == "" { + vaultType = VaultTypeDefault + } + + if vaultType != VaultTypeDefault && vaultType != VaultTypeVirtualPrivate { + return nil, cerrors.Newf(cerrors.InvalidArgument, + "vaultType %q is not one of %s, %s", spec.VaultType, VaultTypeDefault, VaultTypeVirtualPrivate) + } + + v := m.newVaultLocked(spec, vaultType) + + info := m.toVaultInfo(v) + + return &info, nil +} + +// newVaultLocked stores a vault built from spec. +func (m *Mock) newVaultLocked(spec *VaultSpec, vaultType string) *vaultData { + id := m.newOCID(typeVault) + v := &vaultData{ + ID: id, + DisplayName: spec.DisplayName, + VaultType: vaultType, + LifecycleState: StateActive, + TimeCreated: m.now(), + Scope: scope.Scope{Compartment: m.compartmentOr(spec.CompartmentID)}, + FreeformTags: copyTags(spec.FreeformTags), + } + + m.vaults.Set(id, v) + + return v +} + +// GetVault returns a vault by OCID. +func (m *Mock) GetVault(id string) (*VaultInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + v, err := m.vaultLocked(id) + if err != nil { + return nil, err + } + + info := m.toVaultInfo(v) + + return &info, nil +} + +// ListVaults returns the vaults in a compartment, ordered by OCID. +func (m *Mock) ListVaults(compartmentID string) ([]VaultInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + filter := scope.Scope{Compartment: compartmentID} + out := make([]VaultInfo, 0, m.vaults.Len()) + + for _, v := range m.vaults.SortedValues() { + if !v.Scope.Matches(filter) { + continue + } + + out = append(out, m.toVaultInfo(v)) + } + + return out, nil +} + +// UpdateVault replaces a vault's display name and freeform tags. +func (m *Mock) UpdateVault(id string, upd Update) (*VaultInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + v, err := m.vaultLocked(id) + if err != nil { + return nil, err + } + + if upd.DisplayName != nil { + v.DisplayName = *upd.DisplayName + } + + if upd.FreeformTags != nil { + v.FreeformTags = copyTags(upd.FreeformTags) + } + + info := m.toVaultInfo(v) + + return &info, nil +} + +// ScheduleVaultDeletion marks a vault for deletion at the given time, which +// must fall between 7 and 30 days out. An empty time takes the far end, as +// real OCI does. Nothing reaps the vault: it stays PENDING_DELETION until the +// deletion is canceled. +func (m *Mock) ScheduleVaultDeletion(id, at string) (*VaultInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + v, err := m.vaultLocked(id) + if err != nil { + return nil, err + } + + if v.LifecycleState == StatePendingDeletion { + return nil, cerrors.Newf(cerrors.FailedPrecondition, "vault %s is already scheduled for deletion", id) + } + + when, err := m.deletionTime(at, minVaultDeletionDays) + if err != nil { + return nil, err + } + + v.LifecycleState = StatePendingDeletion + v.TimeOfDeletion = when + + info := m.toVaultInfo(v) + + return &info, nil +} + +// CancelVaultDeletion returns a vault scheduled for deletion to ACTIVE. +func (m *Mock) CancelVaultDeletion(id string) (*VaultInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + v, err := m.vaultLocked(id) + if err != nil { + return nil, err + } + + if v.LifecycleState != StatePendingDeletion { + return nil, cerrors.Newf(cerrors.FailedPrecondition, "vault %s is not scheduled for deletion", id) + } + + v.LifecycleState = StateActive + v.TimeOfDeletion = "" + + info := m.toVaultInfo(v) + + return &info, nil +} + +// ChangeVaultCompartment moves a vault to another compartment. The keys and +// secrets it holds keep the compartment they were created in, as in real OCI. +func (m *Mock) ChangeVaultCompartment(id, compartmentID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if compartmentID == "" { + return cerrors.New(cerrors.InvalidArgument, "compartmentId is required") + } + + v, err := m.vaultLocked(id) + if err != nil { + return err + } + + v.Scope = scope.Scope{Compartment: compartmentID} + + return nil +} + +// VaultCompartment returns the compartment a vault lives in, for the work +// request the wire layer records. +func (m *Mock) VaultCompartment(id string) string { + m.mu.RLock() + defer m.mu.RUnlock() + + v, ok := m.vaults.Get(id) + if !ok { + return "" + } + + return v.Scope.Compartment +} + +// vaultLocked reads a vault, reporting OCI's not-found for an unknown OCID. +func (m *Mock) vaultLocked(id string) (*vaultData, error) { + v, ok := m.vaults.Get(id) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "vault %s not found", id) + } + + return v, nil +} + +// requireActiveVaultLocked checks that a vault can still take new keys and +// secrets. +func (m *Mock) requireActiveVaultLocked(id string) error { + v, err := m.vaultLocked(id) + if err != nil { + return err + } + + if v.LifecycleState != StateActive { + return cerrors.Newf(cerrors.FailedPrecondition, "vault %s is %s", id, v.LifecycleState) + } + + return nil +} + +// deletionTime validates a caller-supplied deletion time against OCI's window +// and returns it, defaulting to the far end when the caller names none. +func (m *Mock) deletionTime(at string, minDays int) (string, error) { + now := m.opts.Clock.Now().UTC() + + if at == "" { + return now.Add(maxDeletionDays * day).Format(timeFormat), nil + } + + when, err := time.Parse(timeFormat, at) + if err != nil { + return "", cerrors.Newf(cerrors.InvalidArgument, "timeOfDeletion %q is not an RFC3339 timestamp", at) + } + + when = when.UTC() + + if when.Before(now.Add(time.Duration(minDays)*day)) || when.After(now.Add(maxDeletionDays*day)) { + return "", cerrors.Newf(cerrors.InvalidArgument, + "timeOfDeletion must fall between %d and %d days from now", minDays, maxDeletionDays) + } + + return when.Format(timeFormat), nil +} + +// earliestDeletion is the soonest OCI permits a resource to be deleted, which +// is what the portable delete asks for. +func (m *Mock) earliestDeletion(minDays int) string { + return m.opts.Clock.Now().UTC().Add(time.Duration(minDays) * day).Format(timeFormat) +} + +// compartmentOr falls back to the configured default compartment. +func (m *Mock) compartmentOr(compartmentID string) string { + if compartmentID == "" { + return m.opts.CompartmentID + } + + return compartmentID +} + +// toVaultInfo projects stored vault state, minting the two service endpoints +// real OCI reports for a vault. +func (m *Mock) toVaultInfo(v *vaultData) VaultInfo { + region := m.opts.OCIRegion() + + return VaultInfo{ + ID: v.ID, + CompartmentID: v.Scope.Compartment, + DisplayName: v.DisplayName, + VaultType: v.VaultType, + CryptoEndpoint: "https://" + v.ID + "-crypto.kms." + region + ".oraclecloud.com", + ManagementEndpoint: "https://" + v.ID + "-management.kms." + region + ".oraclecloud.com", + LifecycleState: v.LifecycleState, + TimeCreated: v.TimeCreated, + TimeOfDeletion: v.TimeOfDeletion, + FreeformTags: copyTags(v.FreeformTags), + } +} + +// copyTags returns an independent copy of a tag map, never nil. +func copyTags(tags map[string]string) map[string]string { + out := make(map[string]string, len(tags)) + for k, v := range tags { + out[k] = v + } + + return out +} diff --git a/providers/oci/vault/vault_test.go b/providers/oci/vault/vault_test.go new file mode 100644 index 000000000..f712f0041 --- /dev/null +++ b/providers/oci/vault/vault_test.go @@ -0,0 +1,246 @@ +package vault + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +const ( + testCompartment = "ocid1.compartment.oc1..testaaa" + otherCompart = "ocid1.compartment.oc1..otherbbb" +) + +func newTestMock() *Mock { + fc := config.NewFakeClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + + return New(config.NewOptions( + config.WithClock(fc), + config.WithRegion("us-ashburn-1"), + config.WithCompartmentID(testCompartment), + )) +} + +// newVaultAndKey creates a vault with one AES key in the given compartment. +func newVaultAndKey(t *testing.T, m *Mock, compartmentID string) (vaultID, keyID string) { + t.Helper() + + v, err := m.CreateVault(&VaultSpec{CompartmentID: compartmentID, DisplayName: "v"}) + require.NoError(t, err) + + k, err := m.CreateKey(&KeySpec{ + CompartmentID: compartmentID, + VaultID: v.ID, + DisplayName: "k", + Shape: KeyShape{Algorithm: AlgorithmAES, Length: 32}, + }) + require.NoError(t, err) + + return v.ID, k.ID +} + +func newSecret(t *testing.T, m *Mock, compartmentID, name, value string) *SecretInfo { + t.Helper() + + vaultID, keyID := newVaultAndKey(t, m, compartmentID) + + s, err := m.CreateOCISecret(&SecretSpec{ + CompartmentID: compartmentID, + VaultID: vaultID, + KeyID: keyID, + Name: name, + Content: []byte(value), + }) + require.NoError(t, err) + + return s +} + +func TestCreateVault(t *testing.T) { + tests := []struct { + name string + spec *VaultSpec + expectErr cerrors.Code + expectType string + }{ + { + name: "defaults to a DEFAULT vault", + spec: &VaultSpec{CompartmentID: testCompartment, DisplayName: "v1"}, + expectType: VaultTypeDefault, + }, + { + name: "virtual private", + spec: &VaultSpec{CompartmentID: testCompartment, DisplayName: "v2", VaultType: VaultTypeVirtualPrivate}, + expectType: VaultTypeVirtualPrivate, + }, + { + name: "display name required", + spec: &VaultSpec{CompartmentID: testCompartment}, + expectErr: cerrors.InvalidArgument, + }, + { + name: "unknown vault type", + spec: &VaultSpec{CompartmentID: testCompartment, DisplayName: "v3", VaultType: "SUPER_PRIVATE"}, + expectErr: cerrors.InvalidArgument, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newTestMock() + + info, err := m.CreateVault(tc.spec) + if tc.expectErr != cerrors.OK { + require.Error(t, err) + assert.Equal(t, tc.expectErr, cerrors.GetCode(err)) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectType, info.VaultType) + assert.Equal(t, StateActive, info.LifecycleState) + assert.Equal(t, testCompartment, info.CompartmentID) + assert.True(t, strings.HasPrefix(info.ID, "ocid1.vault.oc1.iad."), "got %q", info.ID) + assert.Contains(t, info.ManagementEndpoint, "us-ashburn-1") + }) + } +} + +func TestVaultNotFound(t *testing.T) { + m := newTestMock() + + _, err := m.GetVault("ocid1.vault.oc1.iad.missing") + require.Error(t, err) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestListVaultsFiltersByCompartment(t *testing.T) { + m := newTestMock() + + _, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "mine"}) + require.NoError(t, err) + + _, err = m.CreateVault(&VaultSpec{CompartmentID: otherCompart, DisplayName: "theirs"}) + require.NoError(t, err) + + mine, err := m.ListVaults(testCompartment) + require.NoError(t, err) + require.Len(t, mine, 1) + assert.Equal(t, "mine", mine[0].DisplayName) + + theirs, err := m.ListVaults(otherCompart) + require.NoError(t, err) + require.Len(t, theirs, 1) + assert.Equal(t, "theirs", theirs[0].DisplayName) + + none, err := m.ListVaults("ocid1.compartment.oc1..emptyccc") + require.NoError(t, err) + assert.Empty(t, none) +} + +func TestUpdateVault(t *testing.T) { + m := newTestMock() + + v, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "before"}) + require.NoError(t, err) + + name := "after" + + got, err := m.UpdateVault(v.ID, Update{DisplayName: &name, FreeformTags: map[string]string{"env": "test"}}) + require.NoError(t, err) + assert.Equal(t, "after", got.DisplayName) + assert.Equal(t, "test", got.FreeformTags["env"]) + + _, err = m.UpdateVault("ocid1.vault.oc1.iad.missing", Update{}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestVaultScheduledDeletionAndCancellation(t *testing.T) { + m := newTestMock() + + v, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) + require.NoError(t, err) + + scheduled, err := m.ScheduleVaultDeletion(v.ID, "") + require.NoError(t, err) + assert.Equal(t, StatePendingDeletion, scheduled.LifecycleState) + assert.Equal(t, "2026-01-31T00:00:00Z", scheduled.TimeOfDeletion) + + // The vault is still there: OCI schedules, it does not delete. + got, err := m.GetVault(v.ID) + require.NoError(t, err) + assert.Equal(t, StatePendingDeletion, got.LifecycleState) + + _, err = m.ScheduleVaultDeletion(v.ID, "") + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + restored, err := m.CancelVaultDeletion(v.ID) + require.NoError(t, err) + assert.Equal(t, StateActive, restored.LifecycleState) + assert.Empty(t, restored.TimeOfDeletion) + + _, err = m.CancelVaultDeletion(v.ID) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) +} + +func TestVaultDeletionWindow(t *testing.T) { + tests := []struct { + name string + at string + expectErr bool + }{ + {name: "inside the window", at: "2026-01-15T00:00:00Z"}, + {name: "too soon", at: "2026-01-02T00:00:00Z", expectErr: true}, + {name: "too late", at: "2026-06-01T00:00:00Z", expectErr: true}, + {name: "not a timestamp", at: "next tuesday", expectErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := newTestMock() + + v, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) + require.NoError(t, err) + + got, err := m.ScheduleVaultDeletion(v.ID, tc.at) + if tc.expectErr { + require.Error(t, err) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.at, got.TimeOfDeletion) + }) + } +} + +func TestChangeVaultCompartment(t *testing.T) { + m := newTestMock() + + v, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) + require.NoError(t, err) + + require.NoError(t, m.ChangeVaultCompartment(v.ID, otherCompart)) + assert.Equal(t, otherCompart, m.VaultCompartment(v.ID)) + + moved, err := m.ListVaults(otherCompart) + require.NoError(t, err) + assert.Len(t, moved, 1) + + left, err := m.ListVaults(testCompartment) + require.NoError(t, err) + assert.Empty(t, left) + + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(m.ChangeVaultCompartment(v.ID, ""))) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(m.ChangeVaultCompartment("ocid1.vault.oc1.iad.x", otherCompart))) + assert.Empty(t, m.VaultCompartment("ocid1.vault.oc1.iad.x")) +} diff --git a/providers/oci/vault/version.go b/providers/oci/vault/version.go new file mode 100644 index 000000000..f5ca3ad89 --- /dev/null +++ b/providers/oci/vault/version.go @@ -0,0 +1,444 @@ +package vault + +import ( + "slices" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// Secret version stages. A version carries several at once: the newest is +// always LATEST, and CURRENT is the one a bundle read resolves to by default. +const ( + StageCurrent = "CURRENT" + StagePending = "PENDING" + StageLatest = "LATEST" + StagePrevious = "PREVIOUS" + StageDeprecated = "DEPRECATED" +) + +// stageOrder is the order stages are reported in, so a version's stage list is +// deterministic. +// +//nolint:gochecknoglobals // a lookup table, read-only after init. +var stageOrder = []string{StageCurrent, StagePending, StageLatest, StagePrevious, StageDeprecated} + +// SecretVersionInfo describes one version of a secret, without its content. +type SecretVersionInfo struct { + SecretID string + VersionNumber int64 + Name string + Stages []string + TimeCreated string + TimeOfDeletion string +} + +// SecretBundle is a secret version together with its content, which is what +// the secret-retrieval data plane serves. +type SecretBundle struct { + SecretID string + VersionNumber int64 + VersionName string + Stages []string + Content []byte + TimeCreated string + TimeOfDeletion string +} + +// BundleSelector picks the version a bundle read returns. At most one field +// may be set; none means the CURRENT version. +type BundleSelector struct { + VersionNumber *int64 + VersionName string + Stage string +} + +type versionData struct { + Number int64 + Name string + Content []byte + Stages []string + TimeCreated string + TimeOfDeletion string +} + +// ListOCISecretVersions returns every version of a secret, oldest first. Real +// OCI takes no compartmentId here — the secret already names one. +func (m *Mock) ListOCISecretVersions(secretID string) ([]SecretVersionInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + s, err := m.secretLocked(secretID) + if err != nil { + return nil, err + } + + out := make([]SecretVersionInfo, 0, len(s.Versions)) + for _, v := range s.Versions { + out = append(out, toVersionInfo(s.ID, v)) + } + + return out, nil +} + +// GetOCISecretVersion returns one version of a secret, without its content. +func (m *Mock) GetOCISecretVersion(secretID string, number int64) (*SecretVersionInfo, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + s, err := m.secretLocked(secretID) + if err != nil { + return nil, err + } + + v, err := versionByNumber(s, number) + if err != nil { + return nil, err + } + + info := toVersionInfo(s.ID, v) + + return &info, nil +} + +// ScheduleSecretVersionDeletion marks one version for deletion. OCI refuses to +// schedule the CURRENT version, which would leave the secret unreadable. +func (m *Mock) ScheduleSecretVersionDeletion(secretID string, number int64, at string) (*SecretVersionInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + s, err := m.secretLocked(secretID) + if err != nil { + return nil, err + } + + v, err := versionByNumber(s, number) + if err != nil { + return nil, err + } + + if slices.Contains(v.Stages, StageCurrent) { + return nil, cerrors.Newf(cerrors.FailedPrecondition, + "version %d is the CURRENT version of secret %s", number, secretID) + } + + if v.TimeOfDeletion != "" { + return nil, cerrors.Newf(cerrors.FailedPrecondition, + "version %d of secret %s is already scheduled for deletion", number, secretID) + } + + when, err := m.deletionTime(at, minSecretDeletionDays) + if err != nil { + return nil, err + } + + v.TimeOfDeletion = when + + info := toVersionInfo(s.ID, v) + + return &info, nil +} + +// CancelSecretVersionDeletion clears a version's scheduled deletion. +func (m *Mock) CancelSecretVersionDeletion(secretID string, number int64) (*SecretVersionInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + + s, err := m.secretLocked(secretID) + if err != nil { + return nil, err + } + + v, err := versionByNumber(s, number) + if err != nil { + return nil, err + } + + if v.TimeOfDeletion == "" { + return nil, cerrors.Newf(cerrors.FailedPrecondition, + "version %d of secret %s is not scheduled for deletion", number, secretID) + } + + v.TimeOfDeletion = "" + + info := toVersionInfo(s.ID, v) + + return &info, nil +} + +// GetSecretBundle returns the content of the version sel picks. +func (m *Mock) GetSecretBundle(secretID string, sel BundleSelector) (*SecretBundle, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + s, err := m.secretLocked(secretID) + if err != nil { + return nil, err + } + + return selectBundle(s, sel) +} + +// GetSecretBundleByName returns a bundle addressed by vault and secret name, +// which is how the data plane's getByName action addresses a secret. +func (m *Mock) GetSecretBundleByName(vaultID, name string, sel BundleSelector) (*SecretBundle, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + s, err := m.secretByVaultAndNameLocked(vaultID, name) + if err != nil { + return nil, err + } + + return selectBundle(s, sel) +} + +// ListSecretBundleVersions returns the versions the data plane can serve, +// oldest first. +func (m *Mock) ListSecretBundleVersions(secretID string) ([]SecretVersionInfo, error) { + return m.ListOCISecretVersions(secretID) +} + +// selectBundle resolves sel against a secret's versions. +func selectBundle(s *secretData, sel BundleSelector) (*SecretBundle, error) { + v, err := selectVersion(s, sel) + if err != nil { + return nil, err + } + + return &SecretBundle{ + SecretID: s.ID, + VersionNumber: v.Number, + VersionName: v.Name, + Stages: slices.Clone(v.Stages), + Content: slices.Clone(v.Content), + TimeCreated: v.TimeCreated, + TimeOfDeletion: v.TimeOfDeletion, + }, nil +} + +// selectVersion picks the version a selector names, rejecting a selector that +// names more than one way to find it. +func selectVersion(s *secretData, sel BundleSelector) (*versionData, error) { + given := 0 + if sel.VersionNumber != nil { + given++ + } + + if sel.VersionName != "" { + given++ + } + + if sel.Stage != "" { + given++ + } + + if given > 1 { + return nil, cerrors.New(cerrors.InvalidArgument, + "at most one of versionNumber, secretVersionName and stage may be given") + } + + switch { + case sel.VersionNumber != nil: + return versionByNumber(s, *sel.VersionNumber) + case sel.VersionName != "": + return versionByName(s, sel.VersionName) + case sel.Stage != "": + return versionByStage(s, sel.Stage) + default: + return versionByStage(s, StageCurrent) + } +} + +func versionByNumber(s *secretData, number int64) (*versionData, error) { + for _, v := range s.Versions { + if v.Number == number { + return v, nil + } + } + + return nil, cerrors.Newf(cerrors.NotFound, "version %d of secret %s not found", number, s.ID) +} + +func versionByName(s *secretData, name string) (*versionData, error) { + for _, v := range s.Versions { + if v.Name != "" && v.Name == name { + return v, nil + } + } + + return nil, cerrors.Newf(cerrors.NotFound, "version %q of secret %s not found", name, s.ID) +} + +func versionByStage(s *secretData, stage string) (*versionData, error) { + if !slices.Contains(stageOrder, stage) { + return nil, cerrors.Newf(cerrors.InvalidArgument, "stage %q is not one of %v", stage, stageOrder) + } + + for _, v := range s.Versions { + if slices.Contains(v.Stages, stage) { + return v, nil + } + } + + return nil, cerrors.Newf(cerrors.NotFound, "secret %s has no version in stage %s", s.ID, stage) +} + +// addVersionLocked appends a version and restages the ones already there. +func (m *Mock) addVersionLocked(s *secretData, content []byte, name, stage string) *versionData { + v := &versionData{ + Number: s.NextVersion, + Name: name, + Content: slices.Clone(content), + TimeCreated: m.now(), + } + s.NextVersion++ + + // LATEST always follows the newest version. + for _, ex := range s.Versions { + ex.Stages = withoutStage(ex.Stages, StageLatest) + } + + if stage == StagePending { + demotePending(s) + + v.Stages = []string{StagePending, StageLatest} + } else { + demoteCurrent(s) + + v.Stages = []string{StageCurrent, StageLatest} + s.CurrentVersion = v.Number + } + + s.Versions = append(s.Versions, v) + s.TimeUpdated = v.TimeCreated + + return v +} + +// promoteVersion makes an existing version the CURRENT one, which is how OCI +// finishes a rotation staged as PENDING. +func promoteVersion(s *secretData, number int64) error { + v, err := versionByNumber(s, number) + if err != nil { + return err + } + + if v.TimeOfDeletion != "" { + return cerrors.Newf(cerrors.FailedPrecondition, + "version %d of secret %s is scheduled for deletion", number, s.ID) + } + + if slices.Contains(v.Stages, StageCurrent) { + return nil + } + + latest := slices.Contains(v.Stages, StageLatest) + + demoteCurrent(s) + + v.Stages = []string{StageCurrent} + if latest { + v.Stages = append(v.Stages, StageLatest) + } + + s.CurrentVersion = number + + return nil +} + +// demoteCurrent slides the stage ladder down one rung: CURRENT becomes +// PREVIOUS and PREVIOUS becomes DEPRECATED. +func demoteCurrent(s *secretData) { + for _, ex := range s.Versions { + switch { + case slices.Contains(ex.Stages, StageCurrent): + ex.Stages = restage(ex.Stages, StageCurrent, StagePrevious) + case slices.Contains(ex.Stages, StagePrevious): + ex.Stages = restage(ex.Stages, StagePrevious, StageDeprecated) + } + } +} + +// demotePending deprecates the version already staged PENDING; OCI holds at +// most one. +func demotePending(s *secretData) { + for _, ex := range s.Versions { + if slices.Contains(ex.Stages, StagePending) { + ex.Stages = restage(ex.Stages, StagePending, StageDeprecated) + } + } +} + +// newVersionStage validates the stage a newly written version enters. OCI +// admits only CURRENT and PENDING there; the rest are reached by being +// displaced. +func newVersionStage(stage string) (string, error) { + switch stage { + case "", StageCurrent: + return StageCurrent, nil + case StagePending: + return StagePending, nil + default: + return "", cerrors.Newf(cerrors.InvalidArgument, + "a new version may enter stage %s or %s, not %q", StageCurrent, StagePending, stage) + } +} + +// versionNameTaken reports whether a secret already has a version by this name. +func versionNameTaken(s *secretData, name string) bool { + if name == "" { + return false + } + + for _, v := range s.Versions { + if v.Name == name { + return true + } + } + + return false +} + +// restage swaps one stage for another, keeping the rest in canonical order. +func restage(stages []string, from, to string) []string { + next := withoutStage(stages, from) + + return sortStages(append(next, to)) +} + +// withoutStage removes a stage from a version's list. +func withoutStage(stages []string, stage string) []string { + out := make([]string, 0, len(stages)) + + for _, s := range stages { + if s != stage { + out = append(out, s) + } + } + + return out +} + +// sortStages puts a stage list in canonical order. +func sortStages(stages []string) []string { + out := make([]string, 0, len(stages)) + + for _, want := range stageOrder { + if slices.Contains(stages, want) { + out = append(out, want) + } + } + + return out +} + +func toVersionInfo(secretID string, v *versionData) SecretVersionInfo { + return SecretVersionInfo{ + SecretID: secretID, + VersionNumber: v.Number, + Name: v.Name, + Stages: slices.Clone(v.Stages), + TimeCreated: v.TimeCreated, + TimeOfDeletion: v.TimeOfDeletion, + } +} diff --git a/providers/oci/vault/version_test.go b/providers/oci/vault/version_test.go new file mode 100644 index 000000000..26842e33d --- /dev/null +++ b/providers/oci/vault/version_test.go @@ -0,0 +1,264 @@ +package vault + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// addVersion writes a new version through the OCI-shaped update path. +func addVersion(t *testing.T, m *Mock, secretID, value, name, stage string) { + t.Helper() + + _, err := m.UpdateOCISecret(secretID, &SecretUpdate{ + Content: []byte(value), + ContentName: name, + Stage: stage, + ContentGiven: true, + }) + require.NoError(t, err) +} + +// stagesOf indexes a secret's version stages by version number. +func stagesOf(t *testing.T, m *Mock, secretID string) map[int64][]string { + t.Helper() + + versions, err := m.ListOCISecretVersions(secretID) + require.NoError(t, err) + + out := make(map[int64][]string, len(versions)) + for _, v := range versions { + out[v.VersionNumber] = v.Stages + } + + return out +} + +func TestVersionStagesSlideAsVersionsAreWritten(t *testing.T) { + m := newTestMock() + s := newSecret(t, m, testCompartment, "staged", "one") + + assert.Equal(t, map[int64][]string{1: {StageCurrent, StageLatest}}, stagesOf(t, m, s.ID)) + + addVersion(t, m, s.ID, "two", "", StageCurrent) + assert.Equal(t, map[int64][]string{ + 1: {StagePrevious}, + 2: {StageCurrent, StageLatest}, + }, stagesOf(t, m, s.ID)) + + addVersion(t, m, s.ID, "three", "", StageCurrent) + assert.Equal(t, map[int64][]string{ + 1: {StageDeprecated}, + 2: {StagePrevious}, + 3: {StageCurrent, StageLatest}, + }, stagesOf(t, m, s.ID)) + + got, err := m.GetOCISecret(s.ID) + require.NoError(t, err) + assert.Equal(t, int64(3), got.CurrentVersionNumber) +} + +func TestPendingVersionIsStagedButNotCurrent(t *testing.T) { + m := newTestMock() + s := newSecret(t, m, testCompartment, "rotating", "live") + + addVersion(t, m, s.ID, "staged", "", StagePending) + assert.Equal(t, map[int64][]string{ + 1: {StageCurrent}, + 2: {StagePending, StageLatest}, + }, stagesOf(t, m, s.ID)) + + // The CURRENT read still returns the live value. + bundle, err := m.GetSecretBundle(s.ID, BundleSelector{}) + require.NoError(t, err) + assert.Equal(t, []byte("live"), bundle.Content) + + pending, err := m.GetSecretBundle(s.ID, BundleSelector{Stage: StagePending}) + require.NoError(t, err) + assert.Equal(t, []byte("staged"), pending.Content) + + // A second PENDING version deprecates the first: OCI holds only one. + addVersion(t, m, s.ID, "restaged", "", StagePending) + assert.Equal(t, map[int64][]string{ + 1: {StageCurrent}, + 2: {StageDeprecated}, + 3: {StagePending, StageLatest}, + }, stagesOf(t, m, s.ID)) +} + +func TestPromotingAPendingVersionFinishesRotation(t *testing.T) { + m := newTestMock() + s := newSecret(t, m, testCompartment, "promotable", "live") + + addVersion(t, m, s.ID, "staged", "", StagePending) + + two := int64(2) + + got, err := m.UpdateOCISecret(s.ID, &SecretUpdate{CurrentVersionNumber: &two}) + require.NoError(t, err) + assert.Equal(t, int64(2), got.CurrentVersionNumber) + + assert.Equal(t, map[int64][]string{ + 1: {StagePrevious}, + 2: {StageCurrent, StageLatest}, + }, stagesOf(t, m, s.ID)) + + bundle, err := m.GetSecretBundle(s.ID, BundleSelector{}) + require.NoError(t, err) + assert.Equal(t, []byte("staged"), bundle.Content) + + // Promoting the version that is already current is a no-op. + _, err = m.UpdateOCISecret(s.ID, &SecretUpdate{CurrentVersionNumber: &two}) + require.NoError(t, err) + + missing := int64(99) + _, err = m.UpdateOCISecret(s.ID, &SecretUpdate{CurrentVersionNumber: &missing}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestNewVersionStageIsRestricted(t *testing.T) { + m := newTestMock() + s := newSecret(t, m, testCompartment, "restricted", "a") + + for _, stage := range []string{StagePrevious, StageDeprecated, StageLatest, "NONSENSE"} { + _, err := m.UpdateOCISecret(s.ID, &SecretUpdate{Content: []byte("b"), Stage: stage, ContentGiven: true}) + require.Error(t, err, stage) + assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err), stage) + } +} + +func TestVersionNamesAreUniquePerSecret(t *testing.T) { + m := newTestMock() + s := newSecret(t, m, testCompartment, "named-versions", "a") + + addVersion(t, m, s.ID, "b", "release-1", StageCurrent) + + _, err := m.UpdateOCISecret(s.ID, &SecretUpdate{ + Content: []byte("c"), + ContentName: "release-1", + ContentGiven: true, + }) + assert.Equal(t, cerrors.AlreadyExists, cerrors.GetCode(err)) +} + +func TestBundleSelectors(t *testing.T) { + m := newTestMock() + s := newSecret(t, m, testCompartment, "selectable", "one") + addVersion(t, m, s.ID, "two", "second", StageCurrent) + + two := int64(2) + missing := int64(9) + + tests := []struct { + name string + sel BundleSelector + expect string + errCode cerrors.Code + }{ + {name: "default is CURRENT", expect: "two"}, + {name: "by number", sel: BundleSelector{VersionNumber: &two}, expect: "two"}, + {name: "by name", sel: BundleSelector{VersionName: "second"}, expect: "two"}, + {name: "by stage", sel: BundleSelector{Stage: StagePrevious}, expect: "one"}, + {name: "unknown number", sel: BundleSelector{VersionNumber: &missing}, errCode: cerrors.NotFound}, + {name: "unknown name", sel: BundleSelector{VersionName: "nope"}, errCode: cerrors.NotFound}, + {name: "empty stage", sel: BundleSelector{Stage: StagePending}, errCode: cerrors.NotFound}, + {name: "invalid stage", sel: BundleSelector{Stage: "SOON"}, errCode: cerrors.InvalidArgument}, + { + name: "two selectors at once", + sel: BundleSelector{VersionNumber: &two, Stage: StageCurrent}, + errCode: cerrors.InvalidArgument, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := m.GetSecretBundle(s.ID, tc.sel) + if tc.errCode != cerrors.OK { + require.Error(t, err) + assert.Equal(t, tc.errCode, cerrors.GetCode(err)) + + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expect, string(got.Content)) + assert.Equal(t, s.ID, got.SecretID) + }) + } +} + +func TestGetSecretBundleByName(t *testing.T) { + m := newTestMock() + s := newSecret(t, m, testCompartment, "by-name", "value") + + got, err := m.GetSecretBundleByName(s.VaultID, "by-name", BundleSelector{}) + require.NoError(t, err) + assert.Equal(t, "value", string(got.Content)) + + _, err = m.GetSecretBundleByName(s.VaultID, "absent", BundleSelector{}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + _, err = m.GetSecretBundle("ocid1.vaultsecret.oc1.iad.x", BundleSelector{}) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestListSecretBundleVersionsMatchesTheManagementListing(t *testing.T) { + m := newTestMock() + s := newSecret(t, m, testCompartment, "listable", "a") + addVersion(t, m, s.ID, "b", "", StageCurrent) + + bundles, err := m.ListSecretBundleVersions(s.ID) + require.NoError(t, err) + + management, err := m.ListOCISecretVersions(s.ID) + require.NoError(t, err) + + assert.Equal(t, management, bundles) + require.Len(t, bundles, 2) + assert.Equal(t, int64(1), bundles[0].VersionNumber) + + _, err = m.ListOCISecretVersions("ocid1.vaultsecret.oc1.iad.x") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} + +func TestSecretVersionScheduledDeletionAndCancellation(t *testing.T) { + m := newTestMock() + s := newSecret(t, m, testCompartment, "versioned", "one") + addVersion(t, m, s.ID, "two", "", StageCurrent) + + // The CURRENT version cannot be scheduled: it would leave the secret unreadable. + _, err := m.ScheduleSecretVersionDeletion(s.ID, 2, "") + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + scheduled, err := m.ScheduleSecretVersionDeletion(s.ID, 1, "") + require.NoError(t, err) + assert.Equal(t, "2026-01-31T00:00:00Z", scheduled.TimeOfDeletion) + + _, err = m.ScheduleSecretVersionDeletion(s.ID, 1, "") + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + // A version pending deletion cannot be promoted back to CURRENT. + one := int64(1) + _, err = m.UpdateOCISecret(s.ID, &SecretUpdate{CurrentVersionNumber: &one}) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + restored, err := m.CancelSecretVersionDeletion(s.ID, 1) + require.NoError(t, err) + assert.Empty(t, restored.TimeOfDeletion) + + _, err = m.CancelSecretVersionDeletion(s.ID, 1) + assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) + + _, err = m.ScheduleSecretVersionDeletion(s.ID, 99, "") + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) + + got, err := m.GetOCISecretVersion(s.ID, 1) + require.NoError(t, err) + assert.Equal(t, int64(1), got.VersionNumber) + + _, err = m.GetOCISecretVersion(s.ID, 99) + assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) +} diff --git a/server/oci/oci.go b/server/oci/oci.go index a367d4810..f27d02fcc 100644 --- a/server/oci/oci.go +++ b/server/oci/oci.go @@ -11,6 +11,7 @@ import ( "github.com/stackshy/cloudemu/v2/server" "github.com/stackshy/cloudemu/v2/server/oci/identity" "github.com/stackshy/cloudemu/v2/server/oci/monitoring" + "github.com/stackshy/cloudemu/v2/server/oci/vault" "github.com/stackshy/cloudemu/v2/server/oci/vcn" "github.com/stackshy/cloudemu/v2/server/oci/workrequest" cachedriver "github.com/stackshy/cloudemu/v2/services/cache/driver" @@ -96,6 +97,10 @@ func New(d Drivers) *server.Server { srv.Register(vcn.New(d.VCN, d.WorkRequests)) } + if d.Vault != nil { + srv.Register(vault.New(d.Vault, d.WorkRequests)) + } + return srv } diff --git a/server/oci/vault/bundle.go b/server/oci/vault/bundle.go new file mode 100644 index 000000000..bb27e5b60 --- /dev/null +++ b/server/oci/vault/bundle.go @@ -0,0 +1,127 @@ +package vault + +import ( + "encoding/base64" + "net/http" + "strconv" + + vaultprovider "github.com/stackshy/cloudemu/v2/providers/oci/vault" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" +) + +// serveBundles routes the secret-retrieval data plane, which reads secret +// values and nothing else. It takes no compartmentId anywhere: a bundle is +// addressed by secret OCID, or by vault and secret name. +func (h *Handler) serveBundles(w http.ResponseWriter, r *http.Request, rt route) { + if rt.seg(idxCollection) != segSecretBundles { + notFound(w, r) + return + } + + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + + switch { + case rt.count() == lenSub && rt.seg(idxID) == segActions: + h.getBundleByName(w, r, rt.seg(idxSub)) + case rt.count() == lenResource: + h.getBundle(w, r, rt.seg(idxID)) + case rt.count() == lenSub && rt.seg(idxSub) == segVersions: + h.listBundleVersions(w, r, rt.seg(idxID)) + default: + notFound(w, r) + } +} + +func (h *Handler) getBundle(w http.ResponseWriter, r *http.Request, secretID string) { + sel, ok := bundleSelector(w, r) + if !ok { + return + } + + bundle, err := h.extras.GetSecretBundle(secretID, sel) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, toBundleResponse(bundle)) +} + +func (h *Handler) getBundleByName(w http.ResponseWriter, r *http.Request, action string) { + if action != actionGetByName { + unknownAction(w, r, action) + return + } + + sel, ok := bundleSelector(w, r) + if !ok { + return + } + + bundle, err := h.extras.GetSecretBundleByName(vaultIDOf(r), r.URL.Query().Get("secretName"), sel) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, toBundleResponse(bundle)) +} + +func (h *Handler) listBundleVersions(w http.ResponseWriter, r *http.Request, secretID string) { + infos, err := h.extras.ListSecretBundleVersions(secretID) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + writeList(w, r, infos, toSecretVersionResponse) +} + +// bundleSelector reads the three mutually exclusive ways a bundle read names a +// version. The driver rejects more than one; the handler only parses them. +func bundleSelector(w http.ResponseWriter, r *http.Request) (vaultprovider.BundleSelector, bool) { + query := r.URL.Query() + + sel := vaultprovider.BundleSelector{ + VersionName: query.Get("secretVersionName"), + Stage: query.Get("stage"), + } + + if raw := query.Get("versionNumber"); raw != "" { + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil || n < 1 { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, + "versionNumber "+raw+" is not a version number") + + return sel, false + } + + sel.VersionNumber = &n + } + + return sel, true +} + +func toBundleResponse(b *vaultprovider.SecretBundle) secretBundleResponse { + return secretBundleResponse{ + SecretID: b.SecretID, + VersionNumber: b.VersionNumber, + VersionName: b.VersionName, + Stages: b.Stages, + TimeCreated: b.TimeCreated, + TimeOfDeletion: b.TimeOfDeletion, + SecretBundleContent: secretBundleContent{ + ContentType: contentTypeBase64, + Content: base64.StdEncoding.EncodeToString(b.Content), + }, + } +} + +// versionIdentifier names a secret version in a work request. OCI gives a +// version no OCID of its own, so the secret and the number identify it. +func versionIdentifier(secretID string, n int64) string { + return secretID + "/versions/" + strconv.FormatInt(n, 10) +} diff --git a/server/oci/vault/extras.go b/server/oci/vault/extras.go new file mode 100644 index 000000000..e31c6685c --- /dev/null +++ b/server/oci/vault/extras.go @@ -0,0 +1,74 @@ +package vault + +import ( + vaultprovider "github.com/stackshy/cloudemu/v2/providers/oci/vault" +) + +// VaultManagement is the KMS vault surface. The portable secrets driver has no +// container above a secret at all, so every vault operation lives here. +// +//nolint:revive // Management alone would not distinguish it from the key and secret surfaces. +type VaultManagement interface { + CreateVault(spec *vaultprovider.VaultSpec) (*vaultprovider.VaultInfo, error) + GetVault(id string) (*vaultprovider.VaultInfo, error) + ListVaults(compartmentID string) ([]vaultprovider.VaultInfo, error) + UpdateVault(id string, upd vaultprovider.Update) (*vaultprovider.VaultInfo, error) + ScheduleVaultDeletion(id, at string) (*vaultprovider.VaultInfo, error) + CancelVaultDeletion(id string) (*vaultprovider.VaultInfo, error) + ChangeVaultCompartment(id, compartmentID string) error + VaultCompartment(id string) string +} + +// KeyManagement is the master encryption key surface. OCI Vault carries key +// management alongside secret storage; no other cloud in this repo puts the +// two behind one service, so the portable driver models neither keys nor the +// rotation that minting a key version performs. +type KeyManagement interface { + CreateKey(spec *vaultprovider.KeySpec) (*vaultprovider.KeyInfo, error) + GetKey(id string) (*vaultprovider.KeyInfo, error) + ListKeys(compartmentID, vaultID string) ([]vaultprovider.KeyInfo, error) + UpdateKey(id string, upd vaultprovider.Update) (*vaultprovider.KeyInfo, error) + ScheduleKeyDeletion(id, at string) (*vaultprovider.KeyInfo, error) + CancelKeyDeletion(id string) (*vaultprovider.KeyInfo, error) + ChangeKeyCompartment(id, compartmentID string) error + KeyCompartment(id string) string + + CreateKeyVersion(keyID string) (*vaultprovider.KeyVersionInfo, error) + GetKeyVersion(keyID, versionID string) (*vaultprovider.KeyVersionInfo, error) + ListKeyVersions(keyID string) ([]vaultprovider.KeyVersionInfo, error) +} + +// SecretManagement is the OCI-shaped secret surface. The portable driver keys +// secrets by name, lists them unscoped, deletes them outright and gives a +// version nothing but an identifier; OCI addresses secrets by OCID, scopes +// them to a compartment and a vault, only ever schedules a deletion, and +// stages each version CURRENT, PENDING, PREVIOUS or DEPRECATED. +type SecretManagement interface { + CreateOCISecret(spec *vaultprovider.SecretSpec) (*vaultprovider.SecretInfo, error) + GetOCISecret(id string) (*vaultprovider.SecretInfo, error) + GetOCISecretByName(vaultID, name string) (*vaultprovider.SecretInfo, error) + ListOCISecrets(compartmentID, vaultID, name string) ([]vaultprovider.SecretInfo, error) + UpdateOCISecret(id string, upd *vaultprovider.SecretUpdate) (*vaultprovider.SecretInfo, error) + ScheduleOCISecretDeletion(id, at string) (*vaultprovider.SecretInfo, error) + CancelOCISecretDeletion(id string) (*vaultprovider.SecretInfo, error) + ChangeSecretCompartment(id, compartmentID string) error + SecretCompartment(id string) string + + ListOCISecretVersions(secretID string) ([]vaultprovider.SecretVersionInfo, error) + GetOCISecretVersion(secretID string, number int64) (*vaultprovider.SecretVersionInfo, error) + ScheduleSecretVersionDeletion(secretID string, number int64, at string) (*vaultprovider.SecretVersionInfo, error) + CancelSecretVersionDeletion(secretID string, number int64) (*vaultprovider.SecretVersionInfo, error) + + GetSecretBundle(secretID string, sel vaultprovider.BundleSelector) (*vaultprovider.SecretBundle, error) + GetSecretBundleByName(vaultID, name string, sel vaultprovider.BundleSelector) (*vaultprovider.SecretBundle, error) + ListSecretBundleVersions(secretID string) ([]vaultprovider.SecretVersionInfo, error) +} + +// Extras is everything OCI Vault does that the portable secrets driver's seven +// operations cannot express. *providers/oci/vault.Mock satisfies it; any +// driver that does not is served 501 for every path this handler claims. +type Extras interface { + VaultManagement + KeyManagement + SecretManagement +} diff --git a/server/oci/vault/handler.go b/server/oci/vault/handler.go new file mode 100644 index 000000000..9c8813c61 --- /dev/null +++ b/server/oci/vault/handler.go @@ -0,0 +1,403 @@ +// Package vault implements OCI Vault's REST API against a CloudEmu secrets +// driver. OCI splits the service across two API versions and CloudEmu claims +// both, because one HTTP server stands in for every OCI endpoint: +// +// /20180608 — KMS management and Vault secret management +// /20190301 — the secret-retrieval data plane +// +// Coverage under /20180608: +// +// POST/GET /vaults — CreateVault, ListVaults +// GET/PUT /vaults/{vaultId} — GetVault, UpdateVault +// POST /vaults/{vaultId}/actions/{scheduleDeletion,cancelDeletion,changeCompartment} +// POST/GET /keys — CreateKey, ListKeys +// GET/PUT /keys/{keyId} — GetKey, UpdateKey +// POST /keys/{keyId}/actions/{scheduleDeletion,cancelDeletion,changeCompartment} +// POST/GET /keys/{keyId}/keyVersions — CreateKeyVersion (rotation), ListKeyVersions +// GET /keys/{keyId}/keyVersions/{id} — GetKeyVersion +// POST/GET /secrets — CreateSecret, ListSecrets +// GET/PUT /secrets/{secretId} — GetSecret, UpdateSecret +// GET /secrets/actions/getByName — GetSecretByName +// POST /secrets/{secretId}/actions/{scheduleDeletion,cancelDeletion,changeCompartment} +// GET /secrets/{secretId}/versions — ListSecretVersions +// GET /secrets/{secretId}/versions/{n} — GetSecretVersion +// POST /secrets/{secretId}/versions/{n}/actions/{scheduleDeletion,cancelDeletion} +// +// Coverage under /20190301: +// +// GET /secretbundles/{secretId} — GetSecretBundle, by versionNumber, secretVersionName or stage +// GET /secretbundles/{secretId}/versions — ListSecretBundleVersions +// GET /secretbundles/actions/getByName — GetSecretBundleByName +// +// The KMS crypto endpoint — encrypt, decrypt, sign, verify and +// generateDataEncryptionKey — shares the /20180608 prefix. CloudEmu stores no +// key material, so those paths are claimed only to answer 501 naming the gap +// rather than leaving a caller with a bare 404. +// +// Deletion is scheduled, never immediate: a vault, key, secret or secret +// version moves to PENDING_DELETION and stays there, since nothing reaps it, +// until the deletion is canceled. +package vault + +import ( + "net/http" + "strconv" + "strings" + + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" + secretsdriver "github.com/stackshy/cloudemu/v2/services/secrets/driver" +) + +// The two API versions OCI Vault is published under. +const ( + apiVersionManagement = "20180608" + apiVersionBundles = "20190301" +) + +// Collections this handler claims. +const ( + segVaults = "vaults" + segKeys = "keys" + segSecrets = "secrets" + segSecretBundles = "secretbundles" +) + +// Sub-collections and the action shape /{collection}/{id}/actions/{action}. +const ( + segKeyVersions = "keyVersions" + segVersions = "versions" + segActions = "actions" +) + +// Crypto endpoint operations, claimed only to report them as unemulated. +const ( + segEncrypt = "encrypt" + segDecrypt = "decrypt" + segGenerate = "generateDataEncryptionKey" + segSign = "sign" + segVerify = "verify" + segExportKey = "exportKey" +) + +// Actions. +const ( + actionScheduleDeletion = "scheduleDeletion" + actionCancelDeletion = "cancelDeletion" + actionChangeCompartment = "changeCompartment" + actionGetByName = "getByName" +) + +// Error codes the handler raises itself. +const ( + codeInvalidParameter = "InvalidParameter" + codeMethodNotAllowed = "MethodNotAllowed" + codeNotImplemented = "NotImplemented" + codeNotFound = "NotAuthorizedOrNotFound" +) + +// maxPathSegments is /{version}/secrets/{id}/versions/{n}/actions/{action}. +const maxPathSegments = 7 + +// Path shapes, as segment counts after the API version. +const ( + lenCollection = 1 + lenResource = 2 + lenSub = 3 + lenSubID = 4 + lenSubAction = 6 +) + +// Segment positions after the API version, for the shapes above. +const ( + idxCollection = 0 + idxID = 1 + idxSub = 2 + idxSubID = 3 + idxSubActions = 4 + idxSubAction = 5 +) + +// Handler serves OCI Vault against a secrets driver. +type Handler struct { + extras Extras + work *workrequest.Store +} + +// New returns a Vault handler. The portable driver is taken so registration +// matches every other service, but OCI addresses vaults, keys and secrets in +// ways its seven operations cannot express, so the handler serves entirely +// through Extras and answers 501 when the driver does not satisfy it. work +// records the mutations real OCI runs asynchronously; a nil store leaves the +// compartment moves unserved and the rest unstamped. +func New(s secretsdriver.Secrets, work *workrequest.Store) *Handler { + extras, _ := s.(Extras) + + return &Handler{extras: extras, work: work} +} + +// route is a parsed Vault path: the API version, and the segments after it. +type route struct { + Version string + Segments []string +} + +// seg returns the i-th segment after the API version, or the empty string. +func (rt route) seg(i int) string { + if i >= len(rt.Segments) { + return "" + } + + return rt.Segments[i] +} + +// count is the number of segments after the API version. +func (rt route) count() int { + return len(rt.Segments) +} + +// Matches claims the Vault collections under /20180608 and the secret bundles +// under /20190301, and nothing else sharing either prefix. +func (*Handler) Matches(r *http.Request) bool { + rt, ok := parsePath(r.URL.Path) + if !ok { + return false + } + + if rt.Version == apiVersionBundles { + return rt.seg(idxCollection) == segSecretBundles + } + + switch rt.seg(idxCollection) { + case segVaults, segKeys, segSecrets, + segEncrypt, segDecrypt, segGenerate, segSign, segVerify, segExportKey: + return true + } + + return false +} + +// ServeHTTP routes on API version, then on collection. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + rt, ok := parsePath(r.URL.Path) + if !ok { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "malformed vault path") + return + } + + if h.extras == nil { + ocirest.WriteError(w, r, http.StatusNotImplemented, codeNotImplemented, + "the wired secrets driver does not implement OCI Vault") + + return + } + + if rt.Version == apiVersionBundles { + h.serveBundles(w, r, rt) + return + } + + switch rt.seg(idxCollection) { + case segVaults: + h.serveVaults(w, r, rt) + case segKeys: + h.serveKeys(w, r, rt) + case segSecrets: + h.serveSecrets(w, r, rt) + default: + unemulatedCrypto(w, r, rt.seg(idxCollection)) + } +} + +// unemulatedCrypto reports a crypto endpoint operation. CloudEmu stores no key +// material — a master encryption key here is a record, not a cipher — so an +// encrypt or sign call would have to invent a ciphertext. +func unemulatedCrypto(w http.ResponseWriter, r *http.Request, operation string) { + ocirest.WriteError(w, r, http.StatusNotImplemented, codeNotImplemented, + operation+" is not emulated: CloudEmu records master encryption keys but stores no key material") +} + +// parsePath splits /{version}/{segments…}. +func parsePath(urlPath string) (route, bool) { + parts := strings.Split(strings.Trim(urlPath, "/"), "/") + if len(parts) < 2 || len(parts) > maxPathSegments { + return route{}, false + } + + if parts[0] != apiVersionManagement && parts[0] != apiVersionBundles { + return route{}, false + } + + for _, p := range parts { + if p == "" { + return route{}, false + } + } + + return route{Version: parts[0], Segments: parts[1:]}, true +} + +// isAction reports whether rt addresses /{collection}/{id}/actions/{action}. +func isAction(rt route) bool { + return rt.count() == lenSubID && rt.seg(idxSub) == segActions +} + +// methodNotAllowed is the response for a verb a collection does not serve. +func methodNotAllowed(w http.ResponseWriter, r *http.Request) { + ocirest.WriteError(w, r, http.StatusMethodNotAllowed, codeMethodNotAllowed, "method not allowed") +} + +// notFound reports a path shape the handler claims but does not serve. +func notFound(w http.ResponseWriter, r *http.Request) { + ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, "no such vault resource") +} + +// unknownAction reports an action a collection does not define. +func unknownAction(w http.ResponseWriter, r *http.Request, action string) { + ocirest.WriteError(w, r, http.StatusNotFound, codeNotFound, "unknown action "+action) +} + +// accept records a work request for a mutation real OCI runs asynchronously +// and stamps the header an SDK waiter polls on. +func (h *Handler) accept(w http.ResponseWriter, operation, compartmentID, entityType, actionType, id string) { + if h.work == nil { + return + } + + wrID := h.work.Accept(operation, compartmentID, workrequest.Resource{ + EntityType: entityType, + ActionType: actionType, + Identifier: id, + }) + + ocirest.SetWorkRequestID(w, wrID) +} + +// decodeDeletion reads a scheduleDeletion body, which OCI allows to be absent. +func decodeDeletion(w http.ResponseWriter, r *http.Request) (string, bool) { + if r.ContentLength == 0 { + return "", true + } + + var req deletionRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return "", false + } + + return req.TimeOfDeletion, true +} + +// changeCompartment moves a resource between compartments. OCI runs it +// asynchronously and answers with nothing but the work request a waiter polls. +func (h *Handler) changeCompartment( + w http.ResponseWriter, r *http.Request, id, operation, entity string, + move func(id, compartmentID string) error, +) { + if h.work == nil { + ocirest.WriteError(w, r, http.StatusNotImplemented, codeNotImplemented, "work requests are not configured") + return + } + + compartmentID, ok := decodeCompartmentMove(w, r) + if !ok { + return + } + + if err := move(id, compartmentID); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, operation, compartmentID, entity, workrequest.ActionUpdated, id) + ocirest.WriteJSON(w, r, http.StatusAccepted, nil) +} + +// decodeCompartmentMove reads a changeCompartment body. +func decodeCompartmentMove(w http.ResponseWriter, r *http.Request) (string, bool) { + var req changeCompartmentRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return "", false + } + + if req.CompartmentID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "compartmentId is required") + return "", false + } + + return req.CompartmentID, true +} + +// rejectDefinedTags refuses a request carrying defined tags, which CloudEmu +// does not model. Accepting and dropping them would leave a caller believing a +// tag namespace had been applied. +func rejectDefinedTags(w http.ResponseWriter, r *http.Request, tags definedTags) bool { + if len(tags) == 0 { + return true + } + + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, + "definedTags is not emulated: CloudEmu models no tag namespaces, use freeformTags") + + return false +} + +// rejectUnmodelled refuses a request naming a field the emulator has no +// behavior for, naming the field rather than dropping it. +func rejectUnmodelled(w http.ResponseWriter, r *http.Request, field string, given bool) bool { + if !given { + return true + } + + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, field+" is not emulated") + + return false +} + +// paginate applies OCI's limit and opaque page cursor, stamping the cursor for +// the next page. The cursor is the offset the next page starts at. +func paginate[T any](w http.ResponseWriter, r *http.Request, items []T) []T { + start := 0 + + if token := ocirest.Page(r); token != "" { + if n, err := strconv.Atoi(token); err == nil && n > 0 { + start = n + } + } + + // items[:0] rather than nil: an empty page is [] on the wire, not null. + if start >= len(items) { + return items[:0] + } + + end := min(start+ocirest.Limit(r), len(items)) + if end < len(items) { + ocirest.SetNextPage(w, strconv.Itoa(end)) + } + + return items[start:end] +} + +// writeList renders a driver listing as a page of wire shapes. +func writeList[T, R any](w http.ResponseWriter, r *http.Request, items []T, render func(*T) R) { + out := make([]R, 0, len(items)) + for i := range items { + out = append(out, render(&items[i])) + } + + ocirest.WriteJSON(w, r, http.StatusOK, paginate(w, r, out)) +} + +// versionNumber parses a secret version number out of the path. +func versionNumber(w http.ResponseWriter, r *http.Request, raw string) (int64, bool) { + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil || n < 1 { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, + "secretVersionNumber "+raw+" is not a version number") + + return 0, false + } + + return n, true +} diff --git a/server/oci/vault/handler_test.go b/server/oci/vault/handler_test.go new file mode 100644 index 000000000..69a1a4cd3 --- /dev/null +++ b/server/oci/vault/handler_test.go @@ -0,0 +1,1337 @@ +package vault_test + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + vaultprovider "github.com/stackshy/cloudemu/v2/providers/oci/vault" + ocivault "github.com/stackshy/cloudemu/v2/server/oci/vault" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" + secretsdriver "github.com/stackshy/cloudemu/v2/services/secrets/driver" +) + +const ( + compartment = "ocid1.compartment.oc1..aaaaaaaatest" + otherCompartment = "ocid1.compartment.oc1..aaaaaaaaother" +) + +// Compile-time check that the OCI Vault mock carries the OCI-only capabilities +// the handler discovers by type assertion. +var _ ocivault.Extras = (*vaultprovider.Mock)(nil) + +type fixture struct { + t *testing.T + handler *ocivault.Handler + mock *vaultprovider.Mock + work *workrequest.Store +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + + opts := config.NewOptions( + config.WithClock(config.NewFakeClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))), + config.WithRegion("us-ashburn-1"), + config.WithCompartmentID(compartment), + ) + mock := vaultprovider.New(opts) + work := workrequest.New(opts) + + return &fixture{t: t, handler: ocivault.New(mock, work), mock: mock, work: work} +} + +func (f *fixture) do(method, target string, body any) *httptest.ResponseRecorder { + f.t.Helper() + + var reader *bytes.Reader + + if body != nil { + raw, err := json.Marshal(body) + require.NoError(f.t, err) + reader = bytes.NewReader(raw) + } else { + reader = bytes.NewReader(nil) + } + + r := httptest.NewRequest(method, target, reader) + w := httptest.NewRecorder() + f.handler.ServeHTTP(w, r) + + return w +} + +func decode(t *testing.T, w *httptest.ResponseRecorder) map[string]any { + t.Helper() + + out := map[string]any{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out)) + + return out +} + +func decodeList(t *testing.T, w *httptest.ResponseRecorder) []map[string]any { + t.Helper() + + var out []map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out)) + + return out +} + +// newVault creates a vault over the wire and returns its OCID. +func (f *fixture) newVault() string { + f.t.Helper() + + w := f.do(http.MethodPost, "/20180608/vaults", map[string]any{ + "compartmentId": compartment, + "displayName": "test-vault", + }) + require.Equal(f.t, http.StatusOK, w.Code, w.Body.String()) + + return decode(f.t, w)["id"].(string) +} + +// newKey creates a key in a vault over the wire and returns its OCID. +func (f *fixture) newKey(vaultID string) string { + f.t.Helper() + + w := f.do(http.MethodPost, "/20180608/keys?vaultId="+vaultID, map[string]any{ + "compartmentId": compartment, + "displayName": "test-key", + "keyShape": map[string]any{"algorithm": "AES", "length": 32}, + }) + require.Equal(f.t, http.StatusOK, w.Code, w.Body.String()) + + return decode(f.t, w)["id"].(string) +} + +// newSecret creates a secret over the wire and returns its OCID. +func (f *fixture) newSecret(vaultID, keyID, name, value string) string { + f.t.Helper() + + w := f.do(http.MethodPost, "/20180608/secrets", map[string]any{ + "compartmentId": compartment, + "vaultId": vaultID, + "keyId": keyID, + "secretName": name, + "secretContent": map[string]any{ + "contentType": "BASE64", + "content": base64.StdEncoding.EncodeToString([]byte(value)), + }, + }) + require.Equal(f.t, http.StatusOK, w.Code, w.Body.String()) + + return decode(f.t, w)["id"].(string) +} + +func TestMatches(t *testing.T) { + h := ocivault.New(vaultprovider.New(config.NewOptions()), nil) + + tests := []struct { + name string + path string + expect bool + }{ + // The management prefix. + {name: "vault collection", path: "/20180608/vaults", expect: true}, + {name: "one vault", path: "/20180608/vaults/ocid1.vault.oc1.iad.a", expect: true}, + {name: "vault action", path: "/20180608/vaults/ocid1.vault.oc1.iad.a/actions/scheduleDeletion", expect: true}, + {name: "key collection", path: "/20180608/keys", expect: true}, + {name: "key versions", path: "/20180608/keys/ocid1.key.oc1.iad.a/keyVersions", expect: true}, + {name: "secret collection", path: "/20180608/secrets", expect: true}, + {name: "secret by name", path: "/20180608/secrets/actions/getByName", expect: true}, + {name: "secret versions", path: "/20180608/secrets/ocid1.vaultsecret.oc1.iad.a/versions", expect: true}, + { + name: "secret version action", + path: "/20180608/secrets/ocid1.vaultsecret.oc1.iad.a/versions/2/actions/scheduleDeletion", + expect: true, + }, + {name: "crypto endpoint is claimed to disclose it", path: "/20180608/encrypt", expect: true}, + {name: "sign is claimed to disclose it", path: "/20180608/sign", expect: true}, + + // The retrieval prefix. + {name: "secret bundle", path: "/20190301/secretbundles/ocid1.vaultsecret.oc1.iad.a", expect: true}, + {name: "bundle versions", path: "/20190301/secretbundles/ocid1.vaultsecret.oc1.iad.a/versions", expect: true}, + {name: "bundle by name", path: "/20190301/secretbundles/actions/getByName", expect: true}, + + // Not ours. + {name: "work requests under our prefix", path: "/20180608/workRequests", expect: false}, + {name: "work request poll", path: "/20180608/workRequests/ocid1.workrequest.oc1.iad.a", expect: false}, + {name: "core networking", path: "/20160918/vcns", expect: false}, + {name: "identity", path: "/20160918/users", expect: false}, + {name: "another service's version prefix", path: "/20190301/secrets", expect: false}, + {name: "secrets management on the retrieval prefix", path: "/20190301/vaults", expect: false}, + {name: "bundles on the management prefix", path: "/20180608/secretbundles", expect: false}, + {name: "version only", path: "/20180608", expect: false}, + {name: "root", path: "/", expect: false}, + {name: "unknown collection", path: "/20180608/vaultUsage", expect: false}, + { + name: "too many segments", + path: "/20180608/secrets/a/versions/2/actions/scheduleDeletion/extra", + expect: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expect, h.Matches(httptest.NewRequest(http.MethodGet, tc.path, nil))) + }) + } +} + +// A driver that implements only the portable interface gets a clean 501 rather +// than a panic or a half-served response. +func TestDriverWithoutExtrasIs501(t *testing.T) { + h := ocivault.New(portableOnly{}, nil) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/20180608/vaults?compartmentId="+compartment, nil)) + + assert.Equal(t, http.StatusNotImplemented, w.Code) + assert.Contains(t, w.Body.String(), "does not implement OCI Vault") +} + +func TestVaultLifecycleOverTheWire(t *testing.T) { + f := newFixture(t) + + created := f.do(http.MethodPost, "/20180608/vaults", map[string]any{ + "compartmentId": compartment, + "displayName": "wire-vault", + "freeformTags": map[string]string{"env": "test"}, + }) + require.Equal(t, http.StatusOK, created.Code, created.Body.String()) + + body := decode(t, created) + id := body["id"].(string) + + assert.Equal(t, "wire-vault", body["displayName"]) + assert.Equal(t, "DEFAULT", body["vaultType"]) + assert.Equal(t, "ACTIVE", body["lifecycleState"]) + assert.NotEmpty(t, created.Header().Get(ocirest.HeaderWorkRequestID)) + assert.NotEmpty(t, created.Header().Get(ocirest.HeaderRequestID)) + + got := f.do(http.MethodGet, "/20180608/vaults/"+id, nil) + require.Equal(t, http.StatusOK, got.Code) + assert.Equal(t, id, decode(t, got)["id"]) + + renamed := f.do(http.MethodPut, "/20180608/vaults/"+id, map[string]any{"displayName": "renamed"}) + require.Equal(t, http.StatusOK, renamed.Code) + assert.Equal(t, "renamed", decode(t, renamed)["displayName"]) + + scheduled := f.do(http.MethodPost, "/20180608/vaults/"+id+"/actions/scheduleDeletion", nil) + require.Equal(t, http.StatusOK, scheduled.Code, scheduled.Body.String()) + assert.Equal(t, "PENDING_DELETION", decode(t, scheduled)["lifecycleState"]) + assert.NotEmpty(t, scheduled.Header().Get(ocirest.HeaderWorkRequestID)) + + canceled := f.do(http.MethodPost, "/20180608/vaults/"+id+"/actions/cancelDeletion", nil) + require.Equal(t, http.StatusOK, canceled.Code) + assert.Equal(t, "ACTIVE", decode(t, canceled)["lifecycleState"]) +} + +func TestVaultErrors(t *testing.T) { + f := newFixture(t) + + tests := []struct { + name string + method string + target string + body any + expect int + }{ + { + name: "list without a compartment", method: http.MethodGet, + target: "/20180608/vaults", expect: http.StatusBadRequest, + }, + { + name: "create without a compartment", method: http.MethodPost, + target: "/20180608/vaults", body: map[string]any{"displayName": "v"}, + expect: http.StatusBadRequest, + }, + { + name: "create with defined tags", method: http.MethodPost, + target: "/20180608/vaults", + body: map[string]any{ + "compartmentId": compartment, "displayName": "v", + "definedTags": map[string]any{"ns": map[string]any{"k": "v"}}, + }, + expect: http.StatusBadRequest, + }, + { + name: "create restoring from a backup", method: http.MethodPost, + target: "/20180608/vaults", + body: map[string]any{ + "compartmentId": compartment, "displayName": "v", + "restoreFromFile": map[string]any{"contentLength": 1}, + }, + expect: http.StatusBadRequest, + }, + { + name: "unknown vault", method: http.MethodGet, + target: "/20180608/vaults/ocid1.vault.oc1.iad.missing", expect: http.StatusNotFound, + }, + { + name: "unknown action", method: http.MethodPost, + target: "/20180608/vaults/ocid1.vault.oc1.iad.a/actions/restore", expect: http.StatusNotFound, + }, + { + name: "delete is not an OCI vault operation", method: http.MethodDelete, + target: "/20180608/vaults/ocid1.vault.oc1.iad.a", expect: http.StatusMethodNotAllowed, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := f.do(tc.method, tc.target, tc.body) + assert.Equal(t, tc.expect, w.Code, w.Body.String()) + assert.NotEmpty(t, decode(t, w)["code"]) + }) + } +} + +func TestMalformedBodyIsRejected(t *testing.T) { + f := newFixture(t) + + r := httptest.NewRequest(http.MethodPost, "/20180608/vaults", bytes.NewReader([]byte("{"))) + w := httptest.NewRecorder() + f.handler.ServeHTTP(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "InvalidParameter", decode(t, w)["code"]) +} + +func TestListVaultsIsScopedToTheCompartment(t *testing.T) { + f := newFixture(t) + f.newVault() + + mine := f.do(http.MethodGet, "/20180608/vaults?compartmentId="+compartment, nil) + require.Equal(t, http.StatusOK, mine.Code) + assert.Len(t, decodeList(t, mine), 1) + + theirs := f.do(http.MethodGet, "/20180608/vaults?compartmentId="+otherCompartment, nil) + require.Equal(t, http.StatusOK, theirs.Code) + assert.Empty(t, decodeList(t, theirs)) +} + +func TestKeyLifecycleAndRotationOverTheWire(t *testing.T) { + f := newFixture(t) + vaultID := f.newVault() + keyID := f.newKey(vaultID) + + got := f.do(http.MethodGet, "/20180608/keys/"+keyID, nil) + require.Equal(t, http.StatusOK, got.Code) + + body := decode(t, got) + assert.Equal(t, vaultID, body["vaultId"]) + assert.Equal(t, "HSM", body["protectionMode"]) + + first := body["currentKeyVersion"].(string) + + rotated := f.do(http.MethodPost, "/20180608/keys/"+keyID+"/keyVersions", nil) + require.Equal(t, http.StatusOK, rotated.Code, rotated.Body.String()) + assert.NotEqual(t, first, decode(t, rotated)["id"]) + assert.NotEmpty(t, rotated.Header().Get(ocirest.HeaderWorkRequestID)) + + versions := f.do(http.MethodGet, "/20180608/keys/"+keyID+"/keyVersions", nil) + require.Equal(t, http.StatusOK, versions.Code) + assert.Len(t, decodeList(t, versions), 2) + + one := f.do(http.MethodGet, "/20180608/keys/"+keyID+"/keyVersions/"+first, nil) + require.Equal(t, http.StatusOK, one.Code) + assert.Equal(t, first, decode(t, one)["id"]) + + scheduled := f.do(http.MethodPost, "/20180608/keys/"+keyID+"/actions/scheduleDeletion", + map[string]any{"timeOfDeletion": "2026-01-15T00:00:00Z"}) + require.Equal(t, http.StatusOK, scheduled.Code, scheduled.Body.String()) + assert.Equal(t, "PENDING_DELETION", decode(t, scheduled)["lifecycleState"]) + + canceled := f.do(http.MethodPost, "/20180608/keys/"+keyID+"/actions/cancelDeletion", nil) + require.Equal(t, http.StatusOK, canceled.Code) + assert.Equal(t, "ENABLED", decode(t, canceled)["lifecycleState"]) +} + +func TestKeyErrors(t *testing.T) { + f := newFixture(t) + vaultID := f.newVault() + keyID := f.newKey(vaultID) + + tests := []struct { + name string + method string + target string + body any + expect int + }{ + { + name: "list without a compartment", method: http.MethodGet, + target: "/20180608/keys", expect: http.StatusBadRequest, + }, + { + name: "create without a shape", method: http.MethodPost, + target: "/20180608/keys?vaultId=" + vaultID, + body: map[string]any{"compartmentId": compartment, "displayName": "k"}, + expect: http.StatusBadRequest, + }, + { + name: "create with an unknown algorithm", method: http.MethodPost, + target: "/20180608/keys?vaultId=" + vaultID, + body: map[string]any{ + "compartmentId": compartment, "displayName": "k", + "keyShape": map[string]any{"algorithm": "TWOFISH", "length": 32}, + }, + expect: http.StatusBadRequest, + }, + { + name: "auto rotation is not emulated", method: http.MethodPost, + target: "/20180608/keys?vaultId=" + vaultID, + body: map[string]any{ + "compartmentId": compartment, "displayName": "k", + "keyShape": map[string]any{"algorithm": "AES", "length": 32}, + "autoKeyRotationDetails": map[string]any{"rotationIntervalInDays": 30}, + }, + expect: http.StatusBadRequest, + }, + { + name: "reshaping a key is refused", method: http.MethodPut, + target: "/20180608/keys/" + keyID, + body: map[string]any{"keyShape": map[string]any{"algorithm": "AES", "length": 16}}, + expect: http.StatusBadRequest, + }, + { + name: "unknown key", method: http.MethodGet, + target: "/20180608/keys/ocid1.key.oc1.iad.missing", expect: http.StatusNotFound, + }, + { + name: "unknown key version", method: http.MethodGet, + target: "/20180608/keys/" + keyID + "/keyVersions/ocid1.keyversion.oc1.iad.x", + expect: http.StatusNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := f.do(tc.method, tc.target, tc.body) + assert.Equal(t, tc.expect, w.Code, w.Body.String()) + }) + } +} + +func TestSecretLifecycleOverTheWire(t *testing.T) { + f := newFixture(t) + vaultID := f.newVault() + keyID := f.newKey(vaultID) + secretID := f.newSecret(vaultID, keyID, "db-password", "hunter2") + + got := f.do(http.MethodGet, "/20180608/secrets/"+secretID, nil) + require.Equal(t, http.StatusOK, got.Code) + + body := decode(t, got) + assert.Equal(t, "db-password", body["secretName"]) + assert.InDelta(t, float64(1), body["currentVersionNumber"], 0.001) + + byName := f.do(http.MethodGet, "/20180608/secrets/actions/getByName?vaultId="+vaultID+"&secretName=db-password", nil) + require.Equal(t, http.StatusOK, byName.Code, byName.Body.String()) + assert.Equal(t, secretID, decode(t, byName)["id"]) + + listed := f.do(http.MethodGet, "/20180608/secrets?compartmentId="+compartment, nil) + require.Equal(t, http.StatusOK, listed.Code) + assert.Len(t, decodeList(t, listed), 1) + + elsewhere := f.do(http.MethodGet, "/20180608/secrets?compartmentId="+otherCompartment, nil) + require.Equal(t, http.StatusOK, elsewhere.Code) + assert.Empty(t, decodeList(t, elsewhere)) + + // A new version through UpdateSecret, then the stages it produced. + updated := f.do(http.MethodPut, "/20180608/secrets/"+secretID, map[string]any{ + "secretContent": map[string]any{ + "contentType": "BASE64", + "content": base64.StdEncoding.EncodeToString([]byte("hunter3")), + "name": "v2", + }, + }) + require.Equal(t, http.StatusOK, updated.Code, updated.Body.String()) + assert.InDelta(t, float64(2), decode(t, updated)["currentVersionNumber"], 0.001) + + versions := f.do(http.MethodGet, "/20180608/secrets/"+secretID+"/versions", nil) + require.Equal(t, http.StatusOK, versions.Code) + + list := decodeList(t, versions) + require.Len(t, list, 2) + assert.Equal(t, []any{"PREVIOUS"}, list[0]["stages"]) + assert.Equal(t, []any{"CURRENT", "LATEST"}, list[1]["stages"]) + + one := f.do(http.MethodGet, "/20180608/secrets/"+secretID+"/versions/1", nil) + require.Equal(t, http.StatusOK, one.Code) + assert.InDelta(t, float64(1), decode(t, one)["versionNumber"], 0.001) +} + +// Scheduled deletion and its cancellation, both answered with headers only. +func TestSecretScheduledDeletionOverTheWire(t *testing.T) { + f := newFixture(t) + vaultID := f.newVault() + keyID := f.newKey(vaultID) + secretID := f.newSecret(vaultID, keyID, "doomed", "v") + + scheduled := f.do(http.MethodPost, "/20180608/secrets/"+secretID+"/actions/scheduleDeletion", nil) + require.Equal(t, http.StatusNoContent, scheduled.Code, scheduled.Body.String()) + assert.NotEmpty(t, scheduled.Header().Get(ocirest.HeaderWorkRequestID)) + + got := f.do(http.MethodGet, "/20180608/secrets/"+secretID, nil) + require.Equal(t, http.StatusOK, got.Code) + assert.Equal(t, "PENDING_DELETION", decode(t, got)["lifecycleState"]) + assert.Equal(t, "2026-01-31T00:00:00Z", decode(t, got)["timeOfDeletion"]) + + again := f.do(http.MethodPost, "/20180608/secrets/"+secretID+"/actions/scheduleDeletion", nil) + assert.Equal(t, http.StatusConflict, again.Code) + + canceled := f.do(http.MethodPost, "/20180608/secrets/"+secretID+"/actions/cancelDeletion", nil) + require.Equal(t, http.StatusNoContent, canceled.Code) + + back := f.do(http.MethodGet, "/20180608/secrets/"+secretID, nil) + assert.Equal(t, "ACTIVE", decode(t, back)["lifecycleState"]) +} + +func TestSecretVersionScheduledDeletionOverTheWire(t *testing.T) { + f := newFixture(t) + vaultID := f.newVault() + keyID := f.newKey(vaultID) + secretID := f.newSecret(vaultID, keyID, "versioned", "one") + + require.Equal(t, http.StatusOK, f.do(http.MethodPut, "/20180608/secrets/"+secretID, map[string]any{ + "secretContent": map[string]any{ + "contentType": "BASE64", + "content": base64.StdEncoding.EncodeToString([]byte("two")), + }, + }).Code) + + base := "/20180608/secrets/" + secretID + "/versions/" + + current := f.do(http.MethodPost, base+"2/actions/scheduleDeletion", nil) + assert.Equal(t, http.StatusConflict, current.Code, current.Body.String()) + + scheduled := f.do(http.MethodPost, base+"1/actions/scheduleDeletion", nil) + require.Equal(t, http.StatusNoContent, scheduled.Code, scheduled.Body.String()) + assert.NotEmpty(t, scheduled.Header().Get(ocirest.HeaderWorkRequestID)) + + canceled := f.do(http.MethodPost, base+"1/actions/cancelDeletion", nil) + require.Equal(t, http.StatusNoContent, canceled.Code) + + bad := f.do(http.MethodPost, base+"abc/actions/scheduleDeletion", nil) + assert.Equal(t, http.StatusBadRequest, bad.Code) + + unknown := f.do(http.MethodPost, base+"1/actions/restore", nil) + assert.Equal(t, http.StatusNotFound, unknown.Code) +} + +func TestSecretErrors(t *testing.T) { + f := newFixture(t) + vaultID := f.newVault() + keyID := f.newKey(vaultID) + + content := map[string]any{"contentType": "BASE64", "content": base64.StdEncoding.EncodeToString([]byte("v"))} + + tests := []struct { + name string + method string + target string + body any + expect int + }{ + { + name: "list without a compartment", method: http.MethodGet, + target: "/20180608/secrets", expect: http.StatusBadRequest, + }, + { + name: "create without content", method: http.MethodPost, target: "/20180608/secrets", + body: map[string]any{ + "compartmentId": compartment, "vaultId": vaultID, "keyId": keyID, "secretName": "s", + }, + expect: http.StatusBadRequest, + }, + { + name: "create with an unsupported content type", method: http.MethodPost, target: "/20180608/secrets", + body: map[string]any{ + "compartmentId": compartment, "vaultId": vaultID, "keyId": keyID, "secretName": "s", + "secretContent": map[string]any{"contentType": "PLAINTEXT", "content": "abc"}, + }, + expect: http.StatusBadRequest, + }, + { + name: "create with content that is not base64", method: http.MethodPost, target: "/20180608/secrets", + body: map[string]any{ + "compartmentId": compartment, "vaultId": vaultID, "keyId": keyID, "secretName": "s", + "secretContent": map[string]any{"contentType": "BASE64", "content": "not base64!"}, + }, + expect: http.StatusBadRequest, + }, + { + name: "secret rules are not emulated", method: http.MethodPost, target: "/20180608/secrets", + body: map[string]any{ + "compartmentId": compartment, "vaultId": vaultID, "keyId": keyID, "secretName": "s", + "secretContent": content, + "secretRules": []any{map[string]any{"ruleType": "SECRET_EXPIRY_RULE"}}, + }, + expect: http.StatusBadRequest, + }, + { + name: "rotation config is not emulated", method: http.MethodPost, target: "/20180608/secrets", + body: map[string]any{ + "compartmentId": compartment, "vaultId": vaultID, "keyId": keyID, "secretName": "s", + "secretContent": content, "rotationConfig": map[string]any{"targetSystemDetails": map[string]any{}}, + }, + expect: http.StatusBadRequest, + }, + { + name: "unknown secret", method: http.MethodGet, + target: "/20180608/secrets/ocid1.vaultsecret.oc1.iad.missing", expect: http.StatusNotFound, + }, + { + name: "getByName without a vault", method: http.MethodGet, + target: "/20180608/secrets/actions/getByName?secretName=s", expect: http.StatusBadRequest, + }, + { + name: "unknown collection action", method: http.MethodGet, + target: "/20180608/secrets/actions/purge", expect: http.StatusNotFound, + }, + { + name: "delete is not an OCI secret operation", method: http.MethodDelete, + target: "/20180608/secrets/ocid1.vaultsecret.oc1.iad.a", expect: http.StatusMethodNotAllowed, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := f.do(tc.method, tc.target, tc.body) + assert.Equal(t, tc.expect, w.Code, w.Body.String()) + }) + } +} + +func TestSecretBundleDataPlane(t *testing.T) { + f := newFixture(t) + vaultID := f.newVault() + keyID := f.newKey(vaultID) + secretID := f.newSecret(vaultID, keyID, "readable", "one") + + require.Equal(t, http.StatusOK, f.do(http.MethodPut, "/20180608/secrets/"+secretID, map[string]any{ + "secretContent": map[string]any{ + "contentType": "BASE64", + "content": base64.StdEncoding.EncodeToString([]byte("two")), + "name": "second", + }, + }).Code) + + tests := []struct { + name string + target string + expect string + }{ + {name: "current by default", target: "/20190301/secretbundles/" + secretID, expect: "two"}, + {name: "by number", target: "/20190301/secretbundles/" + secretID + "?versionNumber=1", expect: "one"}, + {name: "by name", target: "/20190301/secretbundles/" + secretID + "?secretVersionName=second", expect: "two"}, + {name: "by stage", target: "/20190301/secretbundles/" + secretID + "?stage=PREVIOUS", expect: "one"}, + { + name: "by vault and secret name", + target: "/20190301/secretbundles/actions/getByName?vaultId=" + vaultID + "&secretName=readable", + expect: "two", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := f.do(http.MethodGet, tc.target, nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + body := decode(t, w) + content := body["secretBundleContent"].(map[string]any) + assert.Equal(t, "BASE64", content["contentType"]) + + raw, err := base64.StdEncoding.DecodeString(content["content"].(string)) + require.NoError(t, err) + assert.Equal(t, tc.expect, string(raw)) + }) + } + + versions := f.do(http.MethodGet, "/20190301/secretbundles/"+secretID+"/versions", nil) + require.Equal(t, http.StatusOK, versions.Code) + assert.Len(t, decodeList(t, versions), 2) +} + +func TestSecretBundleErrors(t *testing.T) { + f := newFixture(t) + vaultID := f.newVault() + keyID := f.newKey(vaultID) + secretID := f.newSecret(vaultID, keyID, "readable", "one") + + tests := []struct { + name string + method string + target string + expect int + }{ + { + name: "unknown secret", method: http.MethodGet, + target: "/20190301/secretbundles/ocid1.vaultsecret.oc1.iad.missing", expect: http.StatusNotFound, + }, + { + name: "two selectors at once", method: http.MethodGet, + target: "/20190301/secretbundles/" + secretID + "?versionNumber=1&stage=CURRENT", + expect: http.StatusBadRequest, + }, + { + name: "version number that is not a number", method: http.MethodGet, + target: "/20190301/secretbundles/" + secretID + "?versionNumber=latest", expect: http.StatusBadRequest, + }, + { + name: "no version in that stage", method: http.MethodGet, + target: "/20190301/secretbundles/" + secretID + "?stage=PENDING", expect: http.StatusNotFound, + }, + { + name: "writing through the data plane", method: http.MethodPost, + target: "/20190301/secretbundles/" + secretID, expect: http.StatusMethodNotAllowed, + }, + { + name: "unknown data plane action", method: http.MethodGet, + target: "/20190301/secretbundles/actions/list", expect: http.StatusNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := f.do(tc.method, tc.target, nil) + assert.Equal(t, tc.expect, w.Code, w.Body.String()) + }) + } +} + +func TestChangeCompartmentRecordsAWorkRequest(t *testing.T) { + f := newFixture(t) + vaultID := f.newVault() + keyID := f.newKey(vaultID) + secretID := f.newSecret(vaultID, keyID, "movable", "v") + + for _, target := range []string{ + "/20180608/vaults/" + vaultID + "/actions/changeCompartment", + "/20180608/keys/" + keyID + "/actions/changeCompartment", + "/20180608/secrets/" + secretID + "/actions/changeCompartment", + } { + w := f.do(http.MethodPost, target, map[string]any{"compartmentId": otherCompartment}) + require.Equal(t, http.StatusAccepted, w.Code, w.Body.String()) + assert.NotEmpty(t, w.Header().Get(ocirest.HeaderWorkRequestID)) + } + + moved := f.do(http.MethodGet, "/20180608/secrets?compartmentId="+otherCompartment, nil) + require.Equal(t, http.StatusOK, moved.Code) + assert.Len(t, decodeList(t, moved), 1) + + assert.Len(t, f.work.List(otherCompartment), 3) + + bad := f.do(http.MethodPost, "/20180608/vaults/"+vaultID+"/actions/changeCompartment", map[string]any{}) + assert.Equal(t, http.StatusBadRequest, bad.Code) +} + +// The KMS crypto endpoint shares the management prefix; CloudEmu stores no key +// material, so it says so rather than inventing a ciphertext. +func TestCryptoEndpointIsDisclosed(t *testing.T) { + f := newFixture(t) + + for _, op := range []string{"encrypt", "decrypt", "sign", "verify", "generateDataEncryptionKey", "exportKey"} { + w := f.do(http.MethodPost, "/20180608/"+op, map[string]any{}) + assert.Equal(t, http.StatusNotImplemented, w.Code, op) + assert.Contains(t, w.Body.String(), "stores no key material", op) + } +} + +// portableOnly implements the portable driver and nothing else. +type portableOnly struct{} + +func (portableOnly) CreateSecret(_ context.Context, _ secretsdriver.SecretConfig, _ []byte) ( + *secretsdriver.SecretInfo, error) { + return nil, nil //nolint:nilnil // never called; the handler 501s first. +} +func (portableOnly) DeleteSecret(context.Context, string) error { return nil } +func (portableOnly) GetSecret(context.Context, string) (*secretsdriver.SecretInfo, error) { + return nil, nil //nolint:nilnil // never called; the handler 501s first. +} +func (portableOnly) ListSecrets(context.Context) ([]secretsdriver.SecretInfo, error) { return nil, nil } +func (portableOnly) PutSecretValue(context.Context, string, []byte) (*secretsdriver.SecretVersion, error) { + return nil, nil //nolint:nilnil // never called; the handler 501s first. +} +func (portableOnly) GetSecretValue(context.Context, string, string) (*secretsdriver.SecretVersion, error) { + return nil, nil //nolint:nilnil // never called; the handler 501s first. +} +func (portableOnly) ListSecretVersions(context.Context, string) ([]secretsdriver.SecretVersion, error) { + return nil, nil +} + +// Verbs a collection does not serve, and path shapes the handler claims via +// Matches but does not route, must answer cleanly rather than fall through. +func TestUnsupportedVerbsAndUnservedShapes(t *testing.T) { + f := newFixture(t) + vaultID := f.newVault() + keyID := f.newKey(vaultID) + secretID := f.newSecret(vaultID, keyID, "verbs", "v") + + tests := []struct { + name string + method string + target string + expect int + }{ + // Verbs the resource paths do not serve. + { + name: "delete a vault", method: http.MethodDelete, + target: "/20180608/vaults/" + vaultID, expect: http.StatusMethodNotAllowed, + }, + { + name: "delete a key", method: http.MethodDelete, + target: "/20180608/keys/" + keyID, expect: http.StatusMethodNotAllowed, + }, + { + name: "delete a secret", method: http.MethodDelete, + target: "/20180608/secrets/" + secretID, expect: http.StatusMethodNotAllowed, + }, + + // Verbs the collections do not serve. + { + name: "delete the vault collection", method: http.MethodDelete, + target: "/20180608/vaults", expect: http.StatusMethodNotAllowed, + }, + { + name: "delete the key collection", method: http.MethodDelete, + target: "/20180608/keys", expect: http.StatusMethodNotAllowed, + }, + { + name: "delete the secret collection", method: http.MethodDelete, + target: "/20180608/secrets", expect: http.StatusMethodNotAllowed, + }, + { + name: "put the key version collection", method: http.MethodPut, + target: "/20180608/keys/" + keyID + "/keyVersions", expect: http.StatusMethodNotAllowed, + }, + { + name: "delete a key version", method: http.MethodDelete, + target: "/20180608/keys/" + keyID + "/keyVersions/x", expect: http.StatusMethodNotAllowed, + }, + + // Actions are POST-only, except getByName which is GET-only. + { + name: "get a vault action", method: http.MethodGet, + target: "/20180608/vaults/" + vaultID + "/actions/scheduleDeletion", + expect: http.StatusMethodNotAllowed, + }, + { + name: "get a key action", method: http.MethodGet, + target: "/20180608/keys/" + keyID + "/actions/scheduleDeletion", + expect: http.StatusMethodNotAllowed, + }, + { + name: "get a secret action", method: http.MethodGet, + target: "/20180608/secrets/" + secretID + "/actions/scheduleDeletion", + expect: http.StatusMethodNotAllowed, + }, + { + name: "post getByName", method: http.MethodPost, + target: "/20180608/secrets/actions/getByName?vaultId=" + vaultID + "&secretName=verbs", + expect: http.StatusMethodNotAllowed, + }, + { + name: "post a secret version action as GET", method: http.MethodGet, + target: "/20180608/secrets/" + secretID + "/versions/1/actions/scheduleDeletion", + expect: http.StatusMethodNotAllowed, + }, + { + name: "post a bundle read", method: http.MethodPost, + target: "/20190301/secretbundles/" + secretID, expect: http.StatusMethodNotAllowed, + }, + + // Actions the collections do not define. + { + name: "unknown vault action", method: http.MethodPost, + target: "/20180608/vaults/" + vaultID + "/actions/rotate", expect: http.StatusNotFound, + }, + { + name: "unknown key action", method: http.MethodPost, + target: "/20180608/keys/" + keyID + "/actions/rotate", expect: http.StatusNotFound, + }, + { + name: "unknown secret action", method: http.MethodPost, + target: "/20180608/secrets/" + secretID + "/actions/rotate", expect: http.StatusNotFound, + }, + { + name: "unknown secret collection action", method: http.MethodGet, + target: "/20180608/secrets/actions/search", expect: http.StatusNotFound, + }, + { + name: "unknown secret version action", method: http.MethodPost, + target: "/20180608/secrets/" + secretID + "/versions/1/actions/promote", + expect: http.StatusNotFound, + }, + { + name: "unknown bundle action", method: http.MethodGet, + target: "/20190301/secretbundles/actions/search", expect: http.StatusNotFound, + }, + + // Path shapes the handler claims but does not serve. + { + name: "unknown vault sub-collection", method: http.MethodGet, + target: "/20180608/vaults/" + vaultID + "/replicas", expect: http.StatusNotFound, + }, + { + name: "unknown key sub-collection", method: http.MethodGet, + target: "/20180608/keys/" + keyID + "/replicas", expect: http.StatusNotFound, + }, + { + name: "unknown secret sub-collection", method: http.MethodGet, + target: "/20180608/secrets/" + secretID + "/replicas", expect: http.StatusNotFound, + }, + { + name: "secret version sub-shape that is not an action", method: http.MethodGet, + target: "/20180608/secrets/" + secretID + "/versions/1/rotate/now", + expect: http.StatusNotFound, + }, + { + name: "unknown bundle sub-collection", method: http.MethodGet, + target: "/20190301/secretbundles/" + secretID + "/replicas", expect: http.StatusNotFound, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := f.do(tc.method, tc.target, nil) + assert.Equal(t, tc.expect, w.Code, w.Body.String()) + }) + } +} + +// A malformed path under a claimed prefix is a 400, not a panic. +func TestMalformedPathIsRejected(t *testing.T) { + f := newFixture(t) + + w := f.do(http.MethodGet, "/20180608/", nil) + assert.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) +} + +// failingExtras satisfies Extras with a driver that fails every call, so the +// handler's error paths are reachable for the reads that cannot fail against +// the real mock. +type failingExtras struct { + portableOnly +} + +var errDriver = cerrors.New(cerrors.NotFound, "driver is unavailable") + +func (failingExtras) CreateVault(*vaultprovider.VaultSpec) (*vaultprovider.VaultInfo, error) { + return nil, errDriver +} +func (failingExtras) GetVault(string) (*vaultprovider.VaultInfo, error) { return nil, errDriver } +func (failingExtras) ListVaults(string) ([]vaultprovider.VaultInfo, error) { + return nil, errDriver +} + +func (failingExtras) UpdateVault(string, vaultprovider.Update) (*vaultprovider.VaultInfo, error) { + return nil, errDriver +} + +func (failingExtras) ScheduleVaultDeletion(string, string) (*vaultprovider.VaultInfo, error) { + return nil, errDriver +} +func (failingExtras) CancelVaultDeletion(string) (*vaultprovider.VaultInfo, error) { + return nil, errDriver +} +func (failingExtras) ChangeVaultCompartment(string, string) error { return errDriver } +func (failingExtras) VaultCompartment(string) string { return "" } + +func (failingExtras) CreateKey(*vaultprovider.KeySpec) (*vaultprovider.KeyInfo, error) { + return nil, errDriver +} +func (failingExtras) GetKey(string) (*vaultprovider.KeyInfo, error) { return nil, errDriver } +func (failingExtras) ListKeys(string, string) ([]vaultprovider.KeyInfo, error) { + return nil, errDriver +} + +func (failingExtras) UpdateKey(string, vaultprovider.Update) (*vaultprovider.KeyInfo, error) { + return nil, errDriver +} + +func (failingExtras) ScheduleKeyDeletion(string, string) (*vaultprovider.KeyInfo, error) { + return nil, errDriver +} +func (failingExtras) CancelKeyDeletion(string) (*vaultprovider.KeyInfo, error) { + return nil, errDriver +} +func (failingExtras) ChangeKeyCompartment(string, string) error { return errDriver } +func (failingExtras) KeyCompartment(string) string { return "" } +func (failingExtras) CreateKeyVersion(string) (*vaultprovider.KeyVersionInfo, error) { + return nil, errDriver +} + +func (failingExtras) GetKeyVersion(string, string) (*vaultprovider.KeyVersionInfo, error) { + return nil, errDriver +} +func (failingExtras) ListKeyVersions(string) ([]vaultprovider.KeyVersionInfo, error) { + return nil, errDriver +} + +func (failingExtras) CreateOCISecret(*vaultprovider.SecretSpec) (*vaultprovider.SecretInfo, error) { + return nil, errDriver +} +func (failingExtras) GetOCISecret(string) (*vaultprovider.SecretInfo, error) { return nil, errDriver } +func (failingExtras) GetOCISecretByName(string, string) (*vaultprovider.SecretInfo, error) { + return nil, errDriver +} + +func (failingExtras) ListOCISecrets(string, string, string) ([]vaultprovider.SecretInfo, error) { + return nil, errDriver +} + +func (failingExtras) UpdateOCISecret(string, *vaultprovider.SecretUpdate) ( + *vaultprovider.SecretInfo, error) { + return nil, errDriver +} + +func (failingExtras) ScheduleOCISecretDeletion(string, string) (*vaultprovider.SecretInfo, error) { + return nil, errDriver +} +func (failingExtras) CancelOCISecretDeletion(string) (*vaultprovider.SecretInfo, error) { + return nil, errDriver +} +func (failingExtras) ChangeSecretCompartment(string, string) error { return errDriver } +func (failingExtras) SecretCompartment(string) string { return "" } +func (failingExtras) ListOCISecretVersions(string) ([]vaultprovider.SecretVersionInfo, error) { + return nil, errDriver +} + +func (failingExtras) GetOCISecretVersion(string, int64) (*vaultprovider.SecretVersionInfo, error) { + return nil, errDriver +} + +func (failingExtras) ScheduleSecretVersionDeletion(string, int64, string) ( + *vaultprovider.SecretVersionInfo, error) { + return nil, errDriver +} + +func (failingExtras) CancelSecretVersionDeletion(string, int64) ( + *vaultprovider.SecretVersionInfo, error) { + return nil, errDriver +} + +func (failingExtras) GetSecretBundle(string, vaultprovider.BundleSelector) ( + *vaultprovider.SecretBundle, error) { + return nil, errDriver +} + +func (failingExtras) GetSecretBundleByName(string, string, vaultprovider.BundleSelector) ( + *vaultprovider.SecretBundle, error) { + return nil, errDriver +} + +func (failingExtras) ListSecretBundleVersions(string) ([]vaultprovider.SecretVersionInfo, error) { + return nil, errDriver +} + +// Every route surfaces a driver failure rather than swallowing it or writing a +// half-formed success. +func TestDriverErrorsAreSurfaced(t *testing.T) { + h := ocivault.New(failingExtras{}, workrequest.New(config.NewOptions())) + + tests := []struct { + name string + method string + target string + body any + }{ + {name: "create vault", method: http.MethodPost, target: "/20180608/vaults", + body: map[string]any{"compartmentId": compartment, "displayName": "v"}}, + {name: "get vault", method: http.MethodGet, target: "/20180608/vaults/v1"}, + {name: "list vaults", method: http.MethodGet, target: "/20180608/vaults?compartmentId=" + compartment}, + {name: "update vault", method: http.MethodPut, target: "/20180608/vaults/v1", + body: map[string]any{"displayName": "n"}}, + {name: "schedule vault deletion", method: http.MethodPost, + target: "/20180608/vaults/v1/actions/scheduleDeletion"}, + {name: "cancel vault deletion", method: http.MethodPost, + target: "/20180608/vaults/v1/actions/cancelDeletion"}, + {name: "change vault compartment", method: http.MethodPost, + target: "/20180608/vaults/v1/actions/changeCompartment", + body: map[string]any{"compartmentId": otherCompartment}}, + + {name: "create key", method: http.MethodPost, target: "/20180608/keys?vaultId=v1", + body: map[string]any{"compartmentId": compartment, "displayName": "k", + "keyShape": map[string]any{"algorithm": "AES", "length": 32}}}, + {name: "get key", method: http.MethodGet, target: "/20180608/keys/k1"}, + {name: "list keys", method: http.MethodGet, target: "/20180608/keys?compartmentId=" + compartment}, + {name: "update key", method: http.MethodPut, target: "/20180608/keys/k1", + body: map[string]any{"displayName": "n"}}, + {name: "schedule key deletion", method: http.MethodPost, + target: "/20180608/keys/k1/actions/scheduleDeletion"}, + {name: "cancel key deletion", method: http.MethodPost, + target: "/20180608/keys/k1/actions/cancelDeletion"}, + {name: "change key compartment", method: http.MethodPost, + target: "/20180608/keys/k1/actions/changeCompartment", + body: map[string]any{"compartmentId": otherCompartment}}, + {name: "create key version", method: http.MethodPost, target: "/20180608/keys/k1/keyVersions"}, + {name: "list key versions", method: http.MethodGet, target: "/20180608/keys/k1/keyVersions"}, + {name: "get key version", method: http.MethodGet, target: "/20180608/keys/k1/keyVersions/kv1"}, + + {name: "create secret", method: http.MethodPost, target: "/20180608/secrets", + body: map[string]any{"compartmentId": compartment, "vaultId": "v1", "keyId": "k1", + "secretName": "s", "secretContent": map[string]any{"contentType": "BASE64", "content": "dg=="}}}, + {name: "get secret", method: http.MethodGet, target: "/20180608/secrets/s1"}, + {name: "list secrets", method: http.MethodGet, target: "/20180608/secrets?compartmentId=" + compartment}, + {name: "get secret by name", method: http.MethodGet, + target: "/20180608/secrets/actions/getByName?vaultId=v1&secretName=s"}, + {name: "update secret", method: http.MethodPut, target: "/20180608/secrets/s1", + body: map[string]any{"description": "d"}}, + {name: "schedule secret deletion", method: http.MethodPost, + target: "/20180608/secrets/s1/actions/scheduleDeletion"}, + {name: "cancel secret deletion", method: http.MethodPost, + target: "/20180608/secrets/s1/actions/cancelDeletion"}, + {name: "change secret compartment", method: http.MethodPost, + target: "/20180608/secrets/s1/actions/changeCompartment", + body: map[string]any{"compartmentId": otherCompartment}}, + {name: "list secret versions", method: http.MethodGet, target: "/20180608/secrets/s1/versions"}, + {name: "get secret version", method: http.MethodGet, target: "/20180608/secrets/s1/versions/1"}, + {name: "schedule secret version deletion", method: http.MethodPost, + target: "/20180608/secrets/s1/versions/1/actions/scheduleDeletion"}, + {name: "cancel secret version deletion", method: http.MethodPost, + target: "/20180608/secrets/s1/versions/1/actions/cancelDeletion"}, + + {name: "get bundle", method: http.MethodGet, target: "/20190301/secretbundles/s1"}, + {name: "get bundle by name", method: http.MethodGet, + target: "/20190301/secretbundles/actions/getByName?vaultId=v1&secretName=s"}, + {name: "list bundle versions", method: http.MethodGet, + target: "/20190301/secretbundles/s1/versions"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var reader *bytes.Reader + + if tc.body != nil { + raw, err := json.Marshal(tc.body) + require.NoError(t, err) + reader = bytes.NewReader(raw) + } else { + reader = bytes.NewReader(nil) + } + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(tc.method, tc.target, reader)) + assert.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) + }) + } +} + +// A client walks a list by following opc-next-page until the header stops +// coming, and the last page carries no cursor. +func TestListPaginationWalksEveryPage(t *testing.T) { + f := newFixture(t) + vaultID := f.newVault() + keyID := f.newKey(vaultID) + + const total = 5 + + for i := range total { + f.newSecret(vaultID, keyID, "paged-"+strconv.Itoa(i), "v") + } + + var ( + seen []string + page string + pages int + ) + + for { + target := "/20180608/secrets?compartmentId=" + compartment + "&limit=2" + if page != "" { + target += "&page=" + page + } + + w := f.do(http.MethodGet, target, nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + for _, item := range decodeList(t, w) { + seen = append(seen, item["secretName"].(string)) + } + + pages++ + + if page = w.Header().Get("opc-next-page"); page == "" { + break + } + + require.LessOrEqual(t, pages, total, "pagination did not terminate") + } + + assert.Equal(t, 3, pages) + assert.Len(t, seen, total) + assert.Equal(t, + []string{"paged-0", "paged-1", "paged-2", "paged-3", "paged-4"}, seen) +} + +// A page cursor past the end is an empty page, rendered as [] rather than null. +func TestPageBeyondTheEndIsEmpty(t *testing.T) { + f := newFixture(t) + f.newVault() + + w := f.do(http.MethodGet, "/20180608/vaults?compartmentId="+compartment+"&page=99", nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.JSONEq(t, "[]", w.Body.String()) + assert.Empty(t, w.Header().Get("opc-next-page")) +} + +// OCI scopes secret names to the vault, so the same name in a second vault is +// a second secret over the wire too. +func TestSameSecretNameInTwoVaults(t *testing.T) { + f := newFixture(t) + + vaultA := f.newVault() + keyA := f.newKey(vaultA) + vaultB := f.newVault() + keyB := f.newKey(vaultB) + + idA := f.newSecret(vaultA, keyA, "db-password", "from-a") + idB := f.newSecret(vaultB, keyB, "db-password", "from-b") + require.NotEqual(t, idA, idB) + + // getByName resolves within the vault it is given. + for _, tc := range []struct{ vaultID, want string }{{vaultA, idA}, {vaultB, idB}} { + w := f.do(http.MethodGet, + "/20180608/secrets/actions/getByName?vaultId="+tc.vaultID+"&secretName=db-password", nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Equal(t, tc.want, decode(t, w)["id"]) + } + + // Each secret keeps its own value. + for _, tc := range []struct{ id, want string }{{idA, "from-a"}, {idB, "from-b"}} { + w := f.do(http.MethodGet, "/20190301/secretbundles/"+tc.id, nil) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + content := decode(t, w)["secretBundleContent"].(map[string]any) + raw, err := base64.StdEncoding.DecodeString(content["content"].(string)) + require.NoError(t, err) + assert.Equal(t, tc.want, string(raw)) + } + + // The name is still taken within one vault. + w := f.do(http.MethodPost, "/20180608/secrets", map[string]any{ + "compartmentId": compartment, "vaultId": vaultA, "keyId": keyA, + "secretName": "db-password", + "secretContent": map[string]any{ + "contentType": "BASE64", + "content": base64.StdEncoding.EncodeToString([]byte("dup")), + }, + }) + assert.Equal(t, http.StatusConflict, w.Code, w.Body.String()) +} + +// Inputs the handler parses itself, before any driver call. +func TestHandlerLevelInputRejections(t *testing.T) { + f := newFixture(t) + vaultID := f.newVault() + keyID := f.newKey(vaultID) + secretID := f.newSecret(vaultID, keyID, "parsed", "v") + + tests := []struct { + name string + method string + target string + body any + expect int + }{ + { + name: "version number is not a number", method: http.MethodGet, + target: "/20180608/secrets/" + secretID + "/versions/latest", + expect: http.StatusBadRequest, + }, + { + name: "version number is zero", method: http.MethodGet, + target: "/20180608/secrets/" + secretID + "/versions/0", + expect: http.StatusBadRequest, + }, + { + name: "version action on a bad version number", method: http.MethodPost, + target: "/20180608/secrets/" + secretID + "/versions/x/actions/scheduleDeletion", + expect: http.StatusBadRequest, + }, + { + name: "bundle versionNumber is not a number", method: http.MethodGet, + target: "/20190301/secretbundles/" + secretID + "?versionNumber=x", + expect: http.StatusBadRequest, + }, + { + name: "malformed scheduleDeletion body", method: http.MethodPost, + target: "/20180608/vaults/" + vaultID + "/actions/scheduleDeletion", + body: "not-an-object", expect: http.StatusBadRequest, + }, + { + name: "changeCompartment without a compartment", method: http.MethodPost, + target: "/20180608/vaults/" + vaultID + "/actions/changeCompartment", + body: map[string]any{}, expect: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := f.do(tc.method, tc.target, tc.body) + assert.Equal(t, tc.expect, w.Code, w.Body.String()) + }) + } +} + +// Without a work request store the compartment moves cannot be served, and the +// handler says so rather than moving the resource untracked. +func TestChangeCompartmentWithoutWorkRequestsIs501(t *testing.T) { + h := ocivault.New(vaultprovider.New(config.NewOptions()), nil) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, + "/20180608/vaults/v1/actions/changeCompartment", bytes.NewReader([]byte(`{"compartmentId":"c"}`)))) + + assert.Equal(t, http.StatusNotImplemented, w.Code, w.Body.String()) +} + +// Secrets management and KMS are separate OCI services sharing the /20180608 +// prefix, and they answer their deletion actions differently: a secret's is 204 +// with no body, a key's and a vault's are 200 carrying the entity. Pinned so +// the asymmetry is not "tidied" into consistency. +func TestDeletionActionStatusCodesMatchTheService(t *testing.T) { + f := newFixture(t) + vaultID := f.newVault() + keyID := f.newKey(vaultID) + secretID := f.newSecret(vaultID, keyID, "codes", "v") + + // Secrets: 204, no body. + for _, action := range []string{"scheduleDeletion", "cancelDeletion"} { + w := f.do(http.MethodPost, "/20180608/secrets/"+secretID+"/actions/"+action, nil) + require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) + assert.Empty(t, w.Body.String()) + assert.NotEmpty(t, w.Header().Get("opc-work-request-id")) + } + + // Keys and vaults: 200 with the entity. + for _, tc := range []struct{ path, state string }{ + {"/20180608/keys/" + keyID, "ENABLED"}, + {"/20180608/vaults/" + vaultID, "ACTIVE"}, + } { + scheduled := f.do(http.MethodPost, tc.path+"/actions/scheduleDeletion", nil) + require.Equal(t, http.StatusOK, scheduled.Code, scheduled.Body.String()) + assert.Equal(t, "PENDING_DELETION", decode(t, scheduled)["lifecycleState"]) + + canceled := f.do(http.MethodPost, tc.path+"/actions/cancelDeletion", nil) + require.Equal(t, http.StatusOK, canceled.Code, canceled.Body.String()) + assert.Equal(t, tc.state, decode(t, canceled)["lifecycleState"]) + } +} diff --git a/server/oci/vault/key.go b/server/oci/vault/key.go new file mode 100644 index 000000000..99938aef9 --- /dev/null +++ b/server/oci/vault/key.go @@ -0,0 +1,305 @@ +package vault + +import ( + "net/http" + + vaultprovider "github.com/stackshy/cloudemu/v2/providers/oci/vault" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" +) + +// Work request operations the key mutations record. +const ( + opCreateKey = "CREATE_KEY" + opUpdateKey = "UPDATE_KEY" + opScheduleKeyDeletion = "SCHEDULE_KEY_DELETION" + opCancelKeyDeletion = "CANCEL_KEY_DELETION" + opChangeKeyCompartment = "CHANGE_KEY_COMPARTMENT" + opCreateKeyVersion = "CREATE_KEY_VERSION" +) + +// Resource kinds a key work request names. +const ( + entityKey = "key" + entityKeyVersion = "keyversion" +) + +// serveKeys routes the key collection and its key versions. +func (h *Handler) serveKeys(w http.ResponseWriter, r *http.Request, rt route) { + switch { + case rt.count() == lenCollection: + h.serveKeyCollection(w, r) + case rt.count() == lenResource && rt.seg(idxID) != segActions: + h.serveKeyResource(w, r, rt.seg(idxID)) + case isAction(rt): + h.keyAction(w, r, rt.seg(idxID), rt.seg(idxSubID)) + case rt.count() == lenSub && rt.seg(idxSub) == segKeyVersions: + h.serveKeyVersionCollection(w, r, rt.seg(idxID)) + case rt.count() == lenSubID && rt.seg(idxSub) == segKeyVersions: + h.getKeyVersion(w, r, rt.seg(idxID), rt.seg(idxSubID)) + default: + notFound(w, r) + } +} + +func (h *Handler) serveKeyCollection(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + h.createKey(w, r) + case http.MethodGet: + h.listKeys(w, r) + default: + methodNotAllowed(w, r) + } +} + +func (h *Handler) serveKeyResource(w http.ResponseWriter, r *http.Request, id string) { + switch r.Method { + case http.MethodGet: + h.getKey(w, r, id) + case http.MethodPut: + h.updateKey(w, r, id) + default: + methodNotAllowed(w, r) + } +} + +func (h *Handler) serveKeyVersionCollection(w http.ResponseWriter, r *http.Request, keyID string) { + switch r.Method { + case http.MethodPost: + h.createKeyVersion(w, r, keyID) + case http.MethodGet: + h.listKeyVersions(w, r, keyID) + default: + methodNotAllowed(w, r) + } +} + +// keyAction serves the mutations OCI models as actions on a key. +func (h *Handler) keyAction(w http.ResponseWriter, r *http.Request, id, action string) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + switch action { + case actionScheduleDeletion: + h.scheduleKeyDeletion(w, r, id) + case actionCancelDeletion: + h.cancelKeyDeletion(w, r, id) + case actionChangeCompartment: + h.changeCompartment(w, r, id, opChangeKeyCompartment, entityKey, h.extras.ChangeKeyCompartment) + default: + unknownAction(w, r, action) + } +} + +func (h *Handler) createKey(w http.ResponseWriter, r *http.Request) { + var req keyRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if !validKeyRequest(w, r, &req) { + return + } + + if req.CompartmentID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "compartmentId is required") + return + } + + if req.KeyShape == nil { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "keyShape is required") + return + } + + info, err := h.extras.CreateKey(&vaultprovider.KeySpec{ + CompartmentID: req.CompartmentID, + VaultID: vaultIDOf(r), + DisplayName: deref(req.DisplayName), + Shape: toProviderShape(*req.KeyShape), + ProtectionMode: req.ProtectionMode, + FreeformTags: req.FreeformTags, + }) + + h.writeKey(w, r, info, err, opCreateKey, workrequest.ActionCreated) +} + +// validKeyRequest refuses the key inputs CloudEmu does not model. +func validKeyRequest(w http.ResponseWriter, r *http.Request, req *keyRequest) bool { + return rejectDefinedTags(w, r, req.DefinedTags) && + rejectUnmodelled(w, r, "autoKeyRotationDetails", req.AutoKeyRotationDetails != nil) && + rejectUnmodelled(w, r, "externalKeyReference", req.ExternalKeyReference != nil) && + rejectUnmodelled(w, r, "desiredState", req.DesiredState != "") +} + +func (h *Handler) listKeys(w http.ResponseWriter, r *http.Request) { + compartmentID, given := ocirest.RequireCompartmentID(w, r) + if !given { + return + } + + infos, err := h.extras.ListKeys(compartmentID, vaultIDOf(r)) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + writeList(w, r, infos, toKeyResponse) +} + +func (h *Handler) getKey(w http.ResponseWriter, r *http.Request, id string) { + info, err := h.extras.GetKey(id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, toKeyResponse(info)) +} + +func (h *Handler) updateKey(w http.ResponseWriter, r *http.Request, id string) { + var req keyRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if !validKeyRequest(w, r, &req) { + return + } + + if req.KeyShape != nil { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, + "keyShape cannot be changed after a key is created") + + return + } + + info, err := h.extras.UpdateKey(id, vaultprovider.Update{ + DisplayName: req.DisplayName, + FreeformTags: req.FreeformTags, + }) + + h.writeKey(w, r, info, err, opUpdateKey, workrequest.ActionUpdated) +} + +func (h *Handler) scheduleKeyDeletion(w http.ResponseWriter, r *http.Request, id string) { + at, ok := decodeDeletion(w, r) + if !ok { + return + } + + info, err := h.extras.ScheduleKeyDeletion(id, at) + + h.writeKey(w, r, info, err, opScheduleKeyDeletion, workrequest.ActionUpdated) +} + +func (h *Handler) cancelKeyDeletion(w http.ResponseWriter, r *http.Request, id string) { + info, err := h.extras.CancelKeyDeletion(id) + + h.writeKey(w, r, info, err, opCancelKeyDeletion, workrequest.ActionUpdated) +} + +// writeKey records the work request for a key mutation and writes the key it +// produced. +func (h *Handler) writeKey( + w http.ResponseWriter, r *http.Request, info *vaultprovider.KeyInfo, err error, operation, actionType string, +) { + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, operation, info.CompartmentID, entityKey, actionType, info.ID) + ocirest.WriteJSON(w, r, http.StatusOK, toKeyResponse(info)) +} + +// createKeyVersion rotates a key. OCI has no separate rotate operation: a new +// key version becomes the key's current one. +func (h *Handler) createKeyVersion(w http.ResponseWriter, r *http.Request, keyID string) { + info, err := h.extras.CreateKeyVersion(keyID) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opCreateKeyVersion, info.CompartmentID, entityKeyVersion, workrequest.ActionCreated, info.ID) + ocirest.WriteJSON(w, r, http.StatusOK, toKeyVersionResponse(info)) +} + +// listKeyVersions lists a key's versions. Real OCI takes no compartmentId +// here — the key in the path already names one. +func (h *Handler) listKeyVersions(w http.ResponseWriter, r *http.Request, keyID string) { + infos, err := h.extras.ListKeyVersions(keyID) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + writeList(w, r, infos, toKeyVersionResponse) +} + +func (h *Handler) getKeyVersion(w http.ResponseWriter, r *http.Request, keyID, versionID string) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + + info, err := h.extras.GetKeyVersion(keyID, versionID) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, toKeyVersionResponse(info)) +} + +// vaultIDOf reads the vaultId query parameter. The KMS management endpoint is +// per-vault in real OCI, so the vault is addressed by host there; CloudEmu +// serves every vault from one host and takes it as a parameter instead. +func vaultIDOf(r *http.Request) string { + return r.URL.Query().Get("vaultId") +} + +func toProviderShape(shape keyShape) vaultprovider.KeyShape { + return vaultprovider.KeyShape{ + Algorithm: shape.Algorithm, + Length: shape.Length, + CurveID: shape.CurveID, + } +} + +func toKeyResponse(info *vaultprovider.KeyInfo) keyResponse { + return keyResponse{ + ID: info.ID, + CompartmentID: info.CompartmentID, + VaultID: info.VaultID, + DisplayName: info.DisplayName, + KeyShape: keyShape{ + Algorithm: info.Shape.Algorithm, + Length: info.Shape.Length, + CurveID: info.Shape.CurveID, + }, + ProtectionMode: info.ProtectionMode, + LifecycleState: info.LifecycleState, + CurrentKeyVersion: info.CurrentKeyVersion, + TimeCreated: info.TimeCreated, + TimeOfDeletion: info.TimeOfDeletion, + FreeformTags: info.FreeformTags, + DefinedTags: definedTags{}, + } +} + +func toKeyVersionResponse(info *vaultprovider.KeyVersionInfo) keyVersionResponse { + return keyVersionResponse{ + ID: info.ID, + KeyID: info.KeyID, + VaultID: info.VaultID, + CompartmentID: info.CompartmentID, + LifecycleState: info.LifecycleState, + TimeCreated: info.TimeCreated, + } +} diff --git a/server/oci/vault/secret.go b/server/oci/vault/secret.go new file mode 100644 index 000000000..fe10aeb75 --- /dev/null +++ b/server/oci/vault/secret.go @@ -0,0 +1,423 @@ +package vault + +import ( + "encoding/base64" + "net/http" + + vaultprovider "github.com/stackshy/cloudemu/v2/providers/oci/vault" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" +) + +// Work request operations the secret mutations record. +const ( + opCreateSecret = "CREATE_SECRET" + opUpdateSecret = "UPDATE_SECRET" + opScheduleSecretDeletion = "SCHEDULE_SECRET_DELETION" + opCancelSecretDeletion = "CANCEL_SECRET_DELETION" + opChangeSecretCompartment = "CHANGE_SECRET_COMPARTMENT" + opScheduleSecretVersionDeleted = "SCHEDULE_SECRET_VERSION_DELETION" + opCancelSecretVersionDeleted = "CANCEL_SECRET_VERSION_DELETION" +) + +// Resource kinds a secret work request names. +const ( + entitySecret = "secret" + entitySecretVersion = "secretversion" +) + +// serveSecrets routes the secret collection and the paths addressing one +// secret. +func (h *Handler) serveSecrets(w http.ResponseWriter, r *http.Request, rt route) { + switch { + case rt.count() == lenCollection: + h.serveSecretCollection(w, r) + case rt.count() == lenSub && rt.seg(idxID) == segActions: + h.secretCollectionAction(w, r, rt.seg(idxSub)) + case rt.count() == lenResource && rt.seg(idxID) != segActions: + h.serveSecretResource(w, r, rt.seg(idxID)) + case isAction(rt): + h.secretAction(w, r, rt.seg(idxID), rt.seg(idxSubID)) + default: + h.serveSecretVersions(w, r, rt) + } +} + +// serveSecretVersions routes the version sub-collection of one secret. +func (h *Handler) serveSecretVersions(w http.ResponseWriter, r *http.Request, rt route) { + if rt.seg(idxSub) != segVersions { + notFound(w, r) + return + } + + switch { + case rt.count() == lenSub: + h.listSecretVersions(w, r, rt.seg(idxID)) + case rt.count() == lenSubID: + h.getSecretVersion(w, r, rt.seg(idxID), rt.seg(idxSubID)) + case rt.count() == lenSubAction && rt.seg(idxSubActions) == segActions: + h.secretVersionAction(w, r, rt.seg(idxID), rt.seg(idxSubID), rt.seg(idxSubAction)) + default: + notFound(w, r) + } +} + +func (h *Handler) serveSecretCollection(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + h.createSecret(w, r) + case http.MethodGet: + h.listSecrets(w, r) + default: + methodNotAllowed(w, r) + } +} + +func (h *Handler) serveSecretResource(w http.ResponseWriter, r *http.Request, id string) { + switch r.Method { + case http.MethodGet: + h.getSecret(w, r, id) + case http.MethodPut: + h.updateSecret(w, r, id) + default: + methodNotAllowed(w, r) + } +} + +// secretCollectionAction serves the actions addressing the collection rather +// than one secret; getByName is the only one. +func (h *Handler) secretCollectionAction(w http.ResponseWriter, r *http.Request, action string) { + if action != actionGetByName { + unknownAction(w, r, action) + return + } + + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + + info, err := h.extras.GetOCISecretByName(vaultIDOf(r), r.URL.Query().Get("secretName")) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, toSecretResponse(info)) +} + +// secretAction serves the mutations OCI models as actions on a secret. +func (h *Handler) secretAction(w http.ResponseWriter, r *http.Request, id, action string) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + switch action { + case actionScheduleDeletion: + h.scheduleSecretDeletion(w, r, id) + case actionCancelDeletion: + h.cancelSecretDeletion(w, r, id) + case actionChangeCompartment: + h.changeCompartment(w, r, id, opChangeSecretCompartment, entitySecret, h.extras.ChangeSecretCompartment) + default: + unknownAction(w, r, action) + } +} + +func (h *Handler) createSecret(w http.ResponseWriter, r *http.Request) { + var req secretRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if !validSecretRequest(w, r, &req) { + return + } + + if req.CompartmentID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "compartmentId is required") + return + } + + if req.SecretContent == nil { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "secretContent is required") + return + } + + content, ok := decodeContent(w, r, req.SecretContent) + if !ok { + return + } + + info, err := h.extras.CreateOCISecret(&vaultprovider.SecretSpec{ + CompartmentID: req.CompartmentID, + VaultID: req.VaultID, + KeyID: req.KeyID, + Name: req.SecretName, + Description: deref(req.Description), + Content: content, + ContentName: req.SecretContent.Name, + FreeformTags: req.FreeformTags, + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opCreateSecret, info.CompartmentID, entitySecret, workrequest.ActionCreated, info.ID) + ocirest.WriteJSON(w, r, http.StatusOK, toSecretResponse(info)) +} + +// writeSecretAction records the work request for a secret mutation OCI answers +// with headers only: 204 and no body, unlike the key and vault equivalents, +// which are 200 carrying the entity. The asymmetry is real OCI's — secrets +// management and KMS are separate services that merely share the /20180608 +// prefix, and the SDK's ScheduleSecretDeletionResponse carries only +// opc-request-id and opc-work-request-id where ScheduleKeyDeletionResponse +// carries a Key. +func (h *Handler) writeSecretAction( + w http.ResponseWriter, r *http.Request, info *vaultprovider.SecretInfo, err error, operation string, +) { + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, operation, info.CompartmentID, entitySecret, workrequest.ActionUpdated, info.ID) + ocirest.WriteJSON(w, r, http.StatusNoContent, nil) +} + +// validSecretRequest refuses the secret inputs CloudEmu does not model. +func validSecretRequest(w http.ResponseWriter, r *http.Request, req *secretRequest) bool { + return rejectDefinedTags(w, r, req.DefinedTags) && + rejectUnmodelled(w, r, "secretRules", len(req.SecretRules) > 0) && + rejectUnmodelled(w, r, "rotationConfig", req.RotationConfig != nil) && + rejectUnmodelled(w, r, "secretGenerationContext", req.SecretGeneration != nil) +} + +func (h *Handler) listSecrets(w http.ResponseWriter, r *http.Request) { + compartmentID, given := ocirest.RequireCompartmentID(w, r) + if !given { + return + } + + infos, err := h.extras.ListOCISecrets(compartmentID, vaultIDOf(r), r.URL.Query().Get("name")) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + writeList(w, r, infos, toSecretResponse) +} + +func (h *Handler) getSecret(w http.ResponseWriter, r *http.Request, id string) { + info, err := h.extras.GetOCISecret(id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, toSecretResponse(info)) +} + +func (h *Handler) updateSecret(w http.ResponseWriter, r *http.Request, id string) { + var req secretRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if !validSecretRequest(w, r, &req) { + return + } + + upd := &vaultprovider.SecretUpdate{ + Description: req.Description, + KeyID: req.KeyID, + CurrentVersionNumber: req.CurrentVersionNumber, + FreeformTags: req.FreeformTags, + } + + if req.SecretContent != nil { + content, ok := decodeContent(w, r, req.SecretContent) + if !ok { + return + } + + upd.Content = content + upd.ContentName = req.SecretContent.Name + upd.Stage = req.SecretContent.Stage + upd.ContentGiven = true + } + + info, err := h.extras.UpdateOCISecret(id, upd) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opUpdateSecret, info.CompartmentID, entitySecret, workrequest.ActionUpdated, info.ID) + ocirest.WriteJSON(w, r, http.StatusOK, toSecretResponse(info)) +} + +// scheduleSecretDeletion moves a secret to PENDING_DELETION. OCI answers with +// no body here, only the headers a waiter needs. +func (h *Handler) scheduleSecretDeletion(w http.ResponseWriter, r *http.Request, id string) { + at, ok := decodeDeletion(w, r) + if !ok { + return + } + + info, err := h.extras.ScheduleOCISecretDeletion(id, at) + + h.writeSecretAction(w, r, info, err, opScheduleSecretDeletion) +} + +func (h *Handler) cancelSecretDeletion(w http.ResponseWriter, r *http.Request, id string) { + info, err := h.extras.CancelOCISecretDeletion(id) + + h.writeSecretAction(w, r, info, err, opCancelSecretDeletion) +} + +// listSecretVersions lists a secret's versions. Real OCI takes no +// compartmentId here — the secret in the path already names one. +func (h *Handler) listSecretVersions(w http.ResponseWriter, r *http.Request, secretID string) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + + infos, err := h.extras.ListOCISecretVersions(secretID) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + writeList(w, r, infos, toSecretVersionResponse) +} + +func (h *Handler) getSecretVersion(w http.ResponseWriter, r *http.Request, secretID, raw string) { + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + + number, ok := versionNumber(w, r, raw) + if !ok { + return + } + + info, err := h.extras.GetOCISecretVersion(secretID, number) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, toSecretVersionResponse(info)) +} + +// secretVersionAction serves the scheduled deletion of a single version. +func (h *Handler) secretVersionAction(w http.ResponseWriter, r *http.Request, secretID, raw, action string) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + number, ok := versionNumber(w, r, raw) + if !ok { + return + } + + switch action { + case actionScheduleDeletion: + h.scheduleSecretVersionDeletion(w, r, secretID, number) + case actionCancelDeletion: + h.cancelSecretVersionDeletion(w, r, secretID, number) + default: + unknownAction(w, r, action) + } +} + +func (h *Handler) scheduleSecretVersionDeletion(w http.ResponseWriter, r *http.Request, secretID string, n int64) { + at, ok := decodeDeletion(w, r) + if !ok { + return + } + + info, err := h.extras.ScheduleSecretVersionDeletion(secretID, n, at) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.acceptVersion(w, opScheduleSecretVersionDeleted, secretID, info.VersionNumber) + ocirest.WriteJSON(w, r, http.StatusNoContent, nil) +} + +func (h *Handler) cancelSecretVersionDeletion(w http.ResponseWriter, r *http.Request, secretID string, n int64) { + info, err := h.extras.CancelSecretVersionDeletion(secretID, n) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.acceptVersion(w, opCancelSecretVersionDeleted, secretID, info.VersionNumber) + ocirest.WriteJSON(w, r, http.StatusNoContent, nil) +} + +// acceptVersion records a work request against a secret version, which has no +// OCID of its own: OCI identifies it by the secret and the version number. +func (h *Handler) acceptVersion(w http.ResponseWriter, operation, secretID string, n int64) { + h.accept(w, operation, h.extras.SecretCompartment(secretID), entitySecretVersion, + workrequest.ActionUpdated, versionIdentifier(secretID, n)) +} + +// decodeContent reads a secretContent payload, which OCI base64-encodes. +func decodeContent(w http.ResponseWriter, r *http.Request, sc *secretContent) ([]byte, bool) { + if sc.ContentType != "" && sc.ContentType != contentTypeBase64 { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, + "secretContent.contentType "+sc.ContentType+" is not supported; only "+contentTypeBase64+" is") + + return nil, false + } + + content, err := base64.StdEncoding.DecodeString(sc.Content) + if err != nil { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, + "secretContent.content is not valid base64") + + return nil, false + } + + return content, true +} + +func toSecretResponse(info *vaultprovider.SecretInfo) secretResponse { + return secretResponse{ + ID: info.ID, + CompartmentID: info.CompartmentID, + VaultID: info.VaultID, + KeyID: info.KeyID, + SecretName: info.Name, + Description: info.Description, + LifecycleState: info.LifecycleState, + CurrentVersionNumber: info.CurrentVersionNumber, + TimeCreated: info.TimeCreated, + TimeOfDeletion: info.TimeOfDeletion, + FreeformTags: info.FreeformTags, + DefinedTags: definedTags{}, + } +} + +func toSecretVersionResponse(info *vaultprovider.SecretVersionInfo) secretVersionResponse { + return secretVersionResponse{ + SecretID: info.SecretID, + VersionNumber: info.VersionNumber, + Name: info.Name, + Stages: info.Stages, + TimeCreated: info.TimeCreated, + TimeOfDeletion: info.TimeOfDeletion, + } +} diff --git a/server/oci/vault/types.go b/server/oci/vault/types.go new file mode 100644 index 000000000..c0dfeb1e0 --- /dev/null +++ b/server/oci/vault/types.go @@ -0,0 +1,164 @@ +package vault + +import "encoding/json" + +// OCI Vault and KMS REST shapes. + +// definedTags is OCI's namespaced tag map. CloudEmu does not model tag +// namespaces, so a request carrying one is refused rather than silently +// stripped, and responses echo it empty. +type definedTags map[string]map[string]any + +// contentTypeBase64 is the only secret content encoding OCI defines. +const contentTypeBase64 = "BASE64" + +// vaultRequest is the CreateVault and UpdateVault body. +type vaultRequest struct { + CompartmentID string `json:"compartmentId,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + VaultType string `json:"vaultType,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` + + // Unmodelled inputs, claimed so a caller is told rather than ignored. + RestoreFromFile json.RawMessage `json:"restoreFromFile,omitempty"` + RestoreFromObjectStore json.RawMessage `json:"restoreFromObjectStore,omitempty"` + ExternalKeyManagerMetadata json.RawMessage `json:"externalKeyManagerMetadata,omitempty"` +} + +type vaultResponse struct { + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + DisplayName string `json:"displayName"` + VaultType string `json:"vaultType"` + CryptoEndpoint string `json:"cryptoEndpoint"` + ManagementEndpoint string `json:"managementEndpoint"` + LifecycleState string `json:"lifecycleState"` + TimeCreated string `json:"timeCreated"` + TimeOfDeletion string `json:"timeOfDeletion,omitempty"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags definedTags `json:"definedTags"` +} + +// keyShape is the algorithm and size of a master encryption key. +type keyShape struct { + Algorithm string `json:"algorithm"` + Length int `json:"length"` + CurveID string `json:"curveId,omitempty"` +} + +// keyRequest is the CreateKey and UpdateKey body. +type keyRequest struct { + CompartmentID string `json:"compartmentId,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + KeyShape *keyShape `json:"keyShape,omitempty"` + ProtectionMode string `json:"protectionMode,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` + + // Unmodelled inputs, claimed so a caller is told rather than ignored. + AutoKeyRotationDetails json.RawMessage `json:"autoKeyRotationDetails,omitempty"` + ExternalKeyReference json.RawMessage `json:"externalKeyReference,omitempty"` + DesiredState string `json:"desiredState,omitempty"` +} + +type keyResponse struct { + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + VaultID string `json:"vaultId"` + DisplayName string `json:"displayName"` + KeyShape keyShape `json:"keyShape"` + ProtectionMode string `json:"protectionMode"` + LifecycleState string `json:"lifecycleState"` + CurrentKeyVersion string `json:"currentKeyVersion"` + TimeCreated string `json:"timeCreated"` + TimeOfDeletion string `json:"timeOfDeletion,omitempty"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags definedTags `json:"definedTags"` +} + +type keyVersionResponse struct { + ID string `json:"id"` + KeyID string `json:"keyId"` + VaultID string `json:"vaultId"` + CompartmentID string `json:"compartmentId"` + LifecycleState string `json:"lifecycleState"` + TimeCreated string `json:"timeCreated"` +} + +// secretContent is the base64 payload a create or update writes as a new +// version. +type secretContent struct { + ContentType string `json:"contentType"` + Content string `json:"content"` + Name string `json:"name,omitempty"` + Stage string `json:"stage,omitempty"` +} + +// secretRequest is the CreateSecret and UpdateSecret body. +type secretRequest struct { + CompartmentID string `json:"compartmentId,omitempty"` + VaultID string `json:"vaultId,omitempty"` + KeyID string `json:"keyId,omitempty"` + SecretName string `json:"secretName,omitempty"` + Description *string `json:"description,omitempty"` + SecretContent *secretContent `json:"secretContent,omitempty"` + CurrentVersionNumber *int64 `json:"currentVersionNumber,omitempty"` + FreeformTags map[string]string `json:"freeformTags,omitempty"` + DefinedTags definedTags `json:"definedTags,omitempty"` + + // Unmodelled inputs, claimed so a caller is told rather than ignored. + SecretRules []json.RawMessage `json:"secretRules,omitempty"` + RotationConfig json.RawMessage `json:"rotationConfig,omitempty"` + SecretGeneration json.RawMessage `json:"secretGenerationContext,omitempty"` +} + +type secretResponse struct { + ID string `json:"id"` + CompartmentID string `json:"compartmentId"` + VaultID string `json:"vaultId"` + KeyID string `json:"keyId"` + SecretName string `json:"secretName"` + Description string `json:"description,omitempty"` + LifecycleState string `json:"lifecycleState"` + CurrentVersionNumber int64 `json:"currentVersionNumber"` + TimeCreated string `json:"timeCreated"` + TimeOfDeletion string `json:"timeOfDeletion,omitempty"` + FreeformTags map[string]string `json:"freeformTags"` + DefinedTags definedTags `json:"definedTags"` +} + +type secretVersionResponse struct { + SecretID string `json:"secretId"` + VersionNumber int64 `json:"versionNumber"` + Name string `json:"name,omitempty"` + Stages []string `json:"stages"` + TimeCreated string `json:"timeCreated"` + TimeOfDeletion string `json:"timeOfDeletion,omitempty"` +} + +// secretBundleContent carries the version's value, base64 encoded as OCI does. +type secretBundleContent struct { + ContentType string `json:"contentType"` + Content string `json:"content"` +} + +type secretBundleResponse struct { + SecretID string `json:"secretId"` + VersionNumber int64 `json:"versionNumber"` + VersionName string `json:"versionName,omitempty"` + Stages []string `json:"stages"` + TimeCreated string `json:"timeCreated"` + TimeOfDeletion string `json:"timeOfDeletion,omitempty"` + SecretBundleContent secretBundleContent `json:"secretBundleContent"` +} + +// deletionRequest is the body of every scheduleDeletion action. +type deletionRequest struct { + TimeOfDeletion string `json:"timeOfDeletion,omitempty"` +} + +// changeCompartmentRequest is the body of every changeCompartment action. +type changeCompartmentRequest struct { + CompartmentID string `json:"compartmentId"` +} diff --git a/server/oci/vault/vault.go b/server/oci/vault/vault.go new file mode 100644 index 000000000..d1bd888c1 --- /dev/null +++ b/server/oci/vault/vault.go @@ -0,0 +1,212 @@ +package vault + +import ( + "net/http" + + vaultprovider "github.com/stackshy/cloudemu/v2/providers/oci/vault" + "github.com/stackshy/cloudemu/v2/server/oci/workrequest" + "github.com/stackshy/cloudemu/v2/server/wire/ocirest" +) + +// Work request operations the vault mutations record. +const ( + opCreateVault = "CREATE_VAULT" + opUpdateVault = "UPDATE_VAULT" + opScheduleVaultDeletion = "SCHEDULE_VAULT_DELETION" + opCancelVaultDeletion = "CANCEL_VAULT_DELETION" + opChangeVaultCompartment = "CHANGE_VAULT_COMPARTMENT" +) + +// entityVault is the resource kind a vault work request names. +const entityVault = "vault" + +// serveVaults routes the vault collection. +func (h *Handler) serveVaults(w http.ResponseWriter, r *http.Request, rt route) { + switch { + case rt.count() == lenCollection: + h.serveVaultCollection(w, r) + case rt.count() == lenResource && rt.seg(idxID) != segActions: + h.serveVaultResource(w, r, rt.seg(idxID)) + case isAction(rt): + h.vaultAction(w, r, rt.seg(idxID), rt.seg(idxSubID)) + default: + notFound(w, r) + } +} + +func (h *Handler) serveVaultCollection(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + h.createVault(w, r) + case http.MethodGet: + h.listVaults(w, r) + default: + methodNotAllowed(w, r) + } +} + +func (h *Handler) serveVaultResource(w http.ResponseWriter, r *http.Request, id string) { + switch r.Method { + case http.MethodGet: + h.getVault(w, r, id) + case http.MethodPut: + h.updateVault(w, r, id) + default: + methodNotAllowed(w, r) + } +} + +// vaultAction serves the mutations OCI models as actions on a vault. All three +// are asynchronous in real OCI. +func (h *Handler) vaultAction(w http.ResponseWriter, r *http.Request, id, action string) { + if r.Method != http.MethodPost { + methodNotAllowed(w, r) + return + } + + switch action { + case actionScheduleDeletion: + h.scheduleVaultDeletion(w, r, id) + case actionCancelDeletion: + h.cancelVaultDeletion(w, r, id) + case actionChangeCompartment: + h.changeCompartment(w, r, id, opChangeVaultCompartment, entityVault, h.extras.ChangeVaultCompartment) + default: + unknownAction(w, r, action) + } +} + +func (h *Handler) createVault(w http.ResponseWriter, r *http.Request) { + var req vaultRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if !validVaultRequest(w, r, &req) { + return + } + + if req.CompartmentID == "" { + ocirest.WriteError(w, r, http.StatusBadRequest, codeInvalidParameter, "compartmentId is required") + return + } + + info, err := h.extras.CreateVault(&vaultprovider.VaultSpec{ + CompartmentID: req.CompartmentID, + DisplayName: deref(req.DisplayName), + VaultType: req.VaultType, + FreeformTags: req.FreeformTags, + }) + + h.writeVault(w, r, info, err, opCreateVault, workrequest.ActionCreated) +} + +// validVaultRequest refuses the vault inputs CloudEmu does not model. +func validVaultRequest(w http.ResponseWriter, r *http.Request, req *vaultRequest) bool { + return rejectDefinedTags(w, r, req.DefinedTags) && + rejectUnmodelled(w, r, "restoreFromFile", req.RestoreFromFile != nil) && + rejectUnmodelled(w, r, "restoreFromObjectStore", req.RestoreFromObjectStore != nil) && + rejectUnmodelled(w, r, "externalKeyManagerMetadata", req.ExternalKeyManagerMetadata != nil) +} + +func (h *Handler) listVaults(w http.ResponseWriter, r *http.Request) { + compartmentID, given := ocirest.RequireCompartmentID(w, r) + if !given { + return + } + + infos, err := h.extras.ListVaults(compartmentID) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + writeList(w, r, infos, toVaultResponse) +} + +func (h *Handler) getVault(w http.ResponseWriter, r *http.Request, id string) { + info, err := h.extras.GetVault(id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + ocirest.WriteJSON(w, r, http.StatusOK, toVaultResponse(info)) +} + +func (h *Handler) updateVault(w http.ResponseWriter, r *http.Request, id string) { + var req vaultRequest + + if !ocirest.DecodeJSON(w, r, &req) { + return + } + + if !validVaultRequest(w, r, &req) { + return + } + + info, err := h.extras.UpdateVault(id, vaultprovider.Update{ + DisplayName: req.DisplayName, + FreeformTags: req.FreeformTags, + }) + + h.writeVault(w, r, info, err, opUpdateVault, workrequest.ActionUpdated) +} + +func (h *Handler) scheduleVaultDeletion(w http.ResponseWriter, r *http.Request, id string) { + at, ok := decodeDeletion(w, r) + if !ok { + return + } + + info, err := h.extras.ScheduleVaultDeletion(id, at) + + h.writeVault(w, r, info, err, opScheduleVaultDeletion, workrequest.ActionUpdated) +} + +func (h *Handler) cancelVaultDeletion(w http.ResponseWriter, r *http.Request, id string) { + info, err := h.extras.CancelVaultDeletion(id) + + h.writeVault(w, r, info, err, opCancelVaultDeletion, workrequest.ActionUpdated) +} + +// writeVault records the work request for a vault mutation and writes the +// vault it produced. +func (h *Handler) writeVault( + w http.ResponseWriter, r *http.Request, info *vaultprovider.VaultInfo, err error, operation, actionType string, +) { + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, operation, info.CompartmentID, entityVault, actionType, info.ID) + ocirest.WriteJSON(w, r, http.StatusOK, toVaultResponse(info)) +} + +func toVaultResponse(info *vaultprovider.VaultInfo) vaultResponse { + return vaultResponse{ + ID: info.ID, + CompartmentID: info.CompartmentID, + DisplayName: info.DisplayName, + VaultType: info.VaultType, + CryptoEndpoint: info.CryptoEndpoint, + ManagementEndpoint: info.ManagementEndpoint, + LifecycleState: info.LifecycleState, + TimeCreated: info.TimeCreated, + TimeOfDeletion: info.TimeOfDeletion, + FreeformTags: info.FreeformTags, + DefinedTags: definedTags{}, + } +} + +// deref reads an optional string field, which is absent rather than empty when +// an update leaves it alone. +func deref(s *string) string { + if s == nil { + return "" + } + + return *s +}