From 40658bc2d49e560694afdb926d74dc096859e726 Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Sat, 22 Aug 2026 00:03:30 +0530 Subject: [PATCH 1/3] =?UTF-8?q?feat(oci):=20add=20Vault=20=E2=80=94=20vaul?= =?UTF-8?q?ts,=20keys,=20secrets=20and=20secret=20bundles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements OCI Vault against the portable secrets driver. providers/oci/vault holds a Mock over memstore.Store carrying vaults, master encryption keys and their versions, secrets, and secret versions with OCI's CURRENT/PENDING/PREVIOUS/DEPRECATED stages. Deletion is scheduled rather than immediate throughout: a vault, key, secret or version moves to PENDING_DELETION with a timeOfDeletion inside OCI's window and stays there until the deletion is cancelled. The portable DeleteSecret maps onto ScheduleSecretDeletion at the soonest OCI permits, one day out. The portable operations then treat the secret as gone while the OCI surface still lists it and can restore it — the same soft delete the AWS Secrets Manager mock exposes. OCI has no secret outside a vault, so the portable create mints one vault and one AES key on first use. server/oci/vault claims /20180608 for KMS and secret management and /20190301 for the secret-retrieval data plane. Vaults, keys and rotation are OCI-only, so they reach the driver through a consumer-side Extras interface rather than the portable one; a driver that does not satisfy it is served 501. Mutations OCI runs asynchronously record a work request and stamp opc-work-request-id. The KMS crypto endpoint shares the management prefix and is claimed only to report that CloudEmu stores no key material. --- providers/oci/oci.go | 2 + providers/oci/vault/key.go | 448 ++++++++++++++++ providers/oci/vault/key_test.go | 219 ++++++++ providers/oci/vault/portable.go | 218 ++++++++ providers/oci/vault/portable_test.go | 183 +++++++ providers/oci/vault/race_test.go | 167 ++++++ providers/oci/vault/secret.go | 441 +++++++++++++++ providers/oci/vault/secret_test.go | 261 +++++++++ providers/oci/vault/vault.go | 426 +++++++++++++++ providers/oci/vault/vault_test.go | 246 +++++++++ providers/oci/vault/version.go | 444 +++++++++++++++ providers/oci/vault/version_test.go | 264 +++++++++ server/oci/oci.go | 5 + server/oci/vault/bundle.go | 127 +++++ server/oci/vault/extras.go | 72 +++ server/oci/vault/handler.go | 368 +++++++++++++ server/oci/vault/handler_test.go | 775 +++++++++++++++++++++++++++ server/oci/vault/key.go | 331 ++++++++++++ server/oci/vault/secret.go | 420 +++++++++++++++ server/oci/vault/types.go | 164 ++++++ server/oci/vault/vault.go | 238 ++++++++ 21 files changed, 5819 insertions(+) create mode 100644 providers/oci/vault/key.go create mode 100644 providers/oci/vault/key_test.go create mode 100644 providers/oci/vault/portable.go create mode 100644 providers/oci/vault/portable_test.go create mode 100644 providers/oci/vault/race_test.go create mode 100644 providers/oci/vault/secret.go create mode 100644 providers/oci/vault/secret_test.go create mode 100644 providers/oci/vault/vault.go create mode 100644 providers/oci/vault/vault_test.go create mode 100644 providers/oci/vault/version.go create mode 100644 providers/oci/vault/version_test.go create mode 100644 server/oci/vault/bundle.go create mode 100644 server/oci/vault/extras.go create mode 100644 server/oci/vault/handler.go create mode 100644 server/oci/vault/handler_test.go create mode 100644 server/oci/vault/key.go create mode 100644 server/oci/vault/secret.go create mode 100644 server/oci/vault/types.go create mode 100644 server/oci/vault/vault.go 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..55ce8b3bd --- /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.activeVaultLocked(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: StateActive, + 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 = StateActive + 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 != StateActive { + 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: StateActive, + 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..b836cd5c5 --- /dev/null +++ b/providers/oci/vault/key_test.go @@ -0,0 +1,219 @@ +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, StateActive, 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, StateActive, 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)) +} diff --git a/providers/oci/vault/portable.go b/providers/oci/vault/portable.go new file mode 100644 index 000000000..3c026c63e --- /dev/null +++ b/providers/oci/vault/portable.go @@ -0,0 +1,218 @@ +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. +// +// 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. +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") + } + + 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, ok := m.secretByNameLocked(name) + if !ok { + return cerrors.Newf(cerrors.NotFound, "secret %q not found", name) + } + + return m.scheduleSecretLocked(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, ok := m.secretByNameLocked(name) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "secret %q not found", name) + } + + info := toPortableInfo(s) + + return &info, nil +} + +// ListSecrets returns every secret not pending deletion, ordered by OCID. +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, ok := m.secretByNameLocked(name) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "secret %q not found", name) + } + + 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, ok := m.secretByNameLocked(name) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "secret %q not found", name) + } + + 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, ok := m.secretByNameLocked(name) + if !ok { + return nil, cerrors.Newf(cerrors.NotFound, "secret %q not found", name) + } + + out := make([]driver.SecretVersion, 0, len(s.Versions)) + for _, v := range s.Versions { + out = append(out, *toPortableVersion(v, s.CurrentVersion)) + } + + return out, 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..dc40f9032 --- /dev/null +++ b/providers/oci/vault/portable_test.go @@ -0,0 +1,183 @@ +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)) +} diff --git a/providers/oci/vault/race_test.go b/providers/oci/vault/race_test.go new file mode 100644 index 000000000..0ab9bd3bf --- /dev/null +++ b/providers/oci/vault/race_test.go @@ -0,0 +1,167 @@ +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() + s := newSecret(t, m, testCompartment, "hot", "initial") + + 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..745875343 --- /dev/null +++ b/providers/oci/vault/secret.go @@ -0,0 +1,441 @@ +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.activeVaultLocked(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.Name); ok { + return cerrors.Newf(cerrors.AlreadyExists, "secret %q already exists", spec.Name) + } + + 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 m.promoteVersionLocked(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 cancelled. +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 := m.scheduleSecretLocked(s, when); err != nil { + return nil, err + } + + info := toSecretInfo(s) + + return &info, nil +} + +// scheduleSecretLocked moves a secret into PENDING_DELETION. +func (m *Mock) scheduleSecretLocked(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 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.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. 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 cancelled. +func (m *Mock) secretByNameLocked(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. +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") + } + + 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..d3d6fcd46 --- /dev/null +++ b/providers/oci/vault/secret_test.go @@ -0,0 +1,261 @@ +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")) +} diff --git a/providers/oci/vault/vault.go b/providers/oci/vault/vault.go new file mode 100644 index 000000000..211450275 --- /dev/null +++ b/providers/oci/vault/vault.go @@ -0,0 +1,426 @@ +// 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. +const ( + StateActive = "ACTIVE" + 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. +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. +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 cancelled. +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 +} + +// activeVaultLocked reads a vault that can still take new keys and secrets. +func (m *Mock) activeVaultLocked(id string) (*vaultData, error) { + v, err := m.vaultLocked(id) + if err != nil { + return nil, err + } + + if v.LifecycleState != StateActive { + return nil, cerrors.Newf(cerrors.FailedPrecondition, "vault %s is %s", id, v.LifecycleState) + } + + return v, 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..11207ded0 --- /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..a09f2dbe8 --- /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 +} + +// promoteVersionLocked makes an existing version the CURRENT one, which is how +// OCI finishes a rotation staged as PENDING. +func (m *Mock) promoteVersionLocked(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..55586e5e6 --- /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..11bf89b39 --- /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(0) != segSecretBundles { + notFound(w, r) + return + } + + if r.Method != http.MethodGet { + methodNotAllowed(w, r) + return + } + + switch { + case rt.count() == lenSub && rt.seg(1) == segActions: + h.getBundleByName(w, r, rt.seg(2)) + case rt.count() == lenResource: + h.getBundle(w, r, rt.seg(1)) + case rt.count() == lenSub && rt.seg(2) == segVersions: + h.listBundleVersions(w, r, rt.seg(1)) + 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..18bffd4be --- /dev/null +++ b/server/oci/vault/extras.go @@ -0,0 +1,72 @@ +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. +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..6ccf1fd8a --- /dev/null +++ b/server/oci/vault/handler.go @@ -0,0 +1,368 @@ +// 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 cancelled. +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 +) + +// 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(0) == segSecretBundles + } + + switch rt.seg(0) { + 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(0) { + 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(0)) + } +} + +// 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(2) == 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 +} + +// 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 +// behaviour 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..0193bd866 --- /dev/null +++ b/server/oci/vault/handler_test.go @@ -0,0 +1,775 @@ +package vault_test + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + 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)) + + cancelled := f.do(http.MethodPost, "/20180608/vaults/"+id+"/actions/cancelDeletion", nil) + require.Equal(t, http.StatusOK, cancelled.Code) + assert.Equal(t, "ACTIVE", decode(t, cancelled)["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"]) + + cancelled := f.do(http.MethodPost, "/20180608/keys/"+keyID+"/actions/cancelDeletion", nil) + require.Equal(t, http.StatusOK, cancelled.Code) + assert.Equal(t, "ACTIVE", decode(t, cancelled)["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) + + cancelled := f.do(http.MethodPost, "/20180608/secrets/"+secretID+"/actions/cancelDeletion", nil) + require.Equal(t, http.StatusNoContent, cancelled.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)) + + cancelled := f.do(http.MethodPost, base+"1/actions/cancelDeletion", nil) + require.Equal(t, http.StatusNoContent, cancelled.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 +} diff --git a/server/oci/vault/key.go b/server/oci/vault/key.go new file mode 100644 index 000000000..aefbd3b0c --- /dev/null +++ b/server/oci/vault/key.go @@ -0,0 +1,331 @@ +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(1) != segActions: + h.serveKeyResource(w, r, rt.seg(1)) + case isAction(rt): + h.keyAction(w, r, rt.seg(1), rt.seg(3)) + case rt.count() == lenSub && rt.seg(2) == segKeyVersions: + h.serveKeyVersionCollection(w, r, rt.seg(1)) + case rt.count() == lenSubID && rt.seg(2) == segKeyVersions: + h.getKeyVersion(w, r, rt.seg(1), rt.seg(3)) + 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.changeKeyCompartment(w, r, id) + 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 !h.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, + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opCreateKey, info.CompartmentID, entityKey, workrequest.ActionCreated, info.ID) + ocirest.WriteJSON(w, r, http.StatusOK, toKeyResponse(info)) +} + +// validKeyRequest refuses the key inputs CloudEmu does not model. +func (h *Handler) 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 !h.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, + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opUpdateKey, info.CompartmentID, entityKey, workrequest.ActionUpdated, info.ID) + ocirest.WriteJSON(w, r, http.StatusOK, toKeyResponse(info)) +} + +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) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opScheduleKeyDeletion, info.CompartmentID, entityKey, workrequest.ActionUpdated, info.ID) + ocirest.WriteJSON(w, r, http.StatusOK, toKeyResponse(info)) +} + +func (h *Handler) cancelKeyDeletion(w http.ResponseWriter, r *http.Request, id string) { + info, err := h.extras.CancelKeyDeletion(id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opCancelKeyDeletion, info.CompartmentID, entityKey, workrequest.ActionUpdated, info.ID) + ocirest.WriteJSON(w, r, http.StatusOK, toKeyResponse(info)) +} + +func (h *Handler) changeKeyCompartment(w http.ResponseWriter, r *http.Request, id string) { + 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 := h.extras.ChangeKeyCompartment(id, compartmentID); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opChangeKeyCompartment, compartmentID, entityKey, workrequest.ActionUpdated, id) + ocirest.WriteJSON(w, r, http.StatusAccepted, nil) +} + +// 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..ce92e4ee1 --- /dev/null +++ b/server/oci/vault/secret.go @@ -0,0 +1,420 @@ +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, its versions and their actions. +func (h *Handler) serveSecrets(w http.ResponseWriter, r *http.Request, rt route) { + switch { + case rt.count() == lenCollection: + h.serveSecretCollection(w, r) + case rt.count() == lenResource && rt.seg(1) != segActions: + h.serveSecretResource(w, r, rt.seg(1)) + case rt.count() == lenSub && rt.seg(1) == segActions: + h.secretCollectionAction(w, r, rt.seg(2)) + case isAction(rt): + h.secretAction(w, r, rt.seg(1), rt.seg(3)) + case rt.count() == lenSub && rt.seg(2) == segVersions: + h.listSecretVersions(w, r, rt.seg(1)) + case rt.count() == lenSubID && rt.seg(2) == segVersions: + h.getSecretVersion(w, r, rt.seg(1), rt.seg(3)) + case rt.count() == lenSubAction && rt.seg(2) == segVersions && rt.seg(4) == segActions: + h.secretVersionAction(w, r, rt.seg(1), rt.seg(3), rt.seg(5)) + 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.changeSecretCompartment(w, r, id) + 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 !h.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)) +} + +// validSecretRequest refuses the secret inputs CloudEmu does not model. +func (h *Handler) 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 !h.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) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opScheduleSecretDeletion, info.CompartmentID, entitySecret, workrequest.ActionUpdated, info.ID) + ocirest.WriteJSON(w, r, http.StatusNoContent, nil) +} + +func (h *Handler) cancelSecretDeletion(w http.ResponseWriter, r *http.Request, id string) { + info, err := h.extras.CancelOCISecretDeletion(id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opCancelSecretDeletion, info.CompartmentID, entitySecret, workrequest.ActionUpdated, info.ID) + ocirest.WriteJSON(w, r, http.StatusNoContent, nil) +} + +func (h *Handler) changeSecretCompartment(w http.ResponseWriter, r *http.Request, id string) { + 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 := h.extras.ChangeSecretCompartment(id, compartmentID); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opChangeSecretCompartment, compartmentID, entitySecret, workrequest.ActionUpdated, id) + ocirest.WriteJSON(w, r, http.StatusAccepted, nil) +} + +// 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..06cf0c415 --- /dev/null +++ b/server/oci/vault/vault.go @@ -0,0 +1,238 @@ +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(1) != segActions: + h.serveVaultResource(w, r, rt.seg(1)) + case isAction(rt): + h.vaultAction(w, r, rt.seg(1), rt.seg(3)) + 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.changeVaultCompartment(w, r, id) + 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 !h.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, + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opCreateVault, info.CompartmentID, entityVault, workrequest.ActionCreated, info.ID) + ocirest.WriteJSON(w, r, http.StatusOK, toVaultResponse(info)) +} + +// validVaultRequest refuses the vault inputs CloudEmu does not model. +func (h *Handler) 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 !h.validVaultRequest(w, r, req) { + return + } + + info, err := h.extras.UpdateVault(id, vaultprovider.Update{ + DisplayName: req.DisplayName, + FreeformTags: req.FreeformTags, + }) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opUpdateVault, info.CompartmentID, entityVault, workrequest.ActionUpdated, info.ID) + ocirest.WriteJSON(w, r, http.StatusOK, toVaultResponse(info)) +} + +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) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opScheduleVaultDeletion, info.CompartmentID, entityVault, workrequest.ActionUpdated, info.ID) + ocirest.WriteJSON(w, r, http.StatusOK, toVaultResponse(info)) +} + +func (h *Handler) cancelVaultDeletion(w http.ResponseWriter, r *http.Request, id string) { + info, err := h.extras.CancelVaultDeletion(id) + if err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opCancelVaultDeletion, info.CompartmentID, entityVault, workrequest.ActionUpdated, info.ID) + ocirest.WriteJSON(w, r, http.StatusOK, toVaultResponse(info)) +} + +func (h *Handler) changeVaultCompartment(w http.ResponseWriter, r *http.Request, id string) { + 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 := h.extras.ChangeVaultCompartment(id, compartmentID); err != nil { + ocirest.WriteDriverError(w, r, err) + return + } + + h.accept(w, opChangeVaultCompartment, compartmentID, entityVault, workrequest.ActionUpdated, id) + ocirest.WriteJSON(w, r, http.StatusAccepted, nil) +} + +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 +} From 0b92e4a0c5f34eb24a8cdc099fd7316e9fced95d Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Sat, 22 Aug 2026 00:11:09 +0530 Subject: [PATCH 2/3] refactor(oci): tidy the Vault handler and regenerate coverage docs Collapses the three changeCompartment handlers and the per-resource mutation tails onto shared helpers, names the path segment positions rather than indexing by number, passes the create specs by pointer, and splits the secret router so the version sub-collection routes on its own. docs/coverage/oci/vault.md lists the seven portable operations and nothing more: vaults, keys and rotation reach the driver through the handler's Extras interface, so they stay out of the AWS, Azure and GCP coverage docs. --- docs/coverage/coverage.json | 3 +- docs/coverage/oci/vault.md | 20 ++++++ providers/oci/vault/key.go | 6 +- providers/oci/vault/key_test.go | 14 ++--- providers/oci/vault/portable.go | 8 +-- providers/oci/vault/secret.go | 24 +++---- providers/oci/vault/secret_test.go | 36 +++++------ providers/oci/vault/vault.go | 21 ++++--- providers/oci/vault/vault_test.go | 28 ++++----- providers/oci/vault/version.go | 6 +- providers/oci/vault/version_test.go | 14 ++--- server/oci/vault/bundle.go | 12 ++-- server/oci/vault/extras.go | 13 ++-- server/oci/vault/handler.go | 49 ++++++++++++--- server/oci/vault/handler_test.go | 20 +++--- server/oci/vault/key.go | 74 +++++++--------------- server/oci/vault/secret.go | 98 ++++++++++++++--------------- server/oci/vault/vault.go | 74 +++++++--------------- 18 files changed, 265 insertions(+), 255 deletions(-) create mode 100644 docs/coverage/oci/vault.md 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/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/providers/oci/vault/key.go b/providers/oci/vault/key.go index 55ce8b3bd..e02259d27 100644 --- a/providers/oci/vault/key.go +++ b/providers/oci/vault/key.go @@ -98,7 +98,7 @@ type keyVersionData struct { // CreateKey creates a master encryption key in a vault, along with its first // key version. -func (m *Mock) CreateKey(spec KeySpec) (*KeyInfo, error) { +func (m *Mock) CreateKey(spec *KeySpec) (*KeyInfo, error) { m.mu.Lock() defer m.mu.Unlock() @@ -110,7 +110,7 @@ func (m *Mock) CreateKey(spec KeySpec) (*KeyInfo, error) { return nil, cerrors.New(cerrors.InvalidArgument, "vaultId is required") } - if _, err := m.activeVaultLocked(spec.VaultID); err != nil { + if err := m.requireActiveVaultLocked(spec.VaultID); err != nil { return nil, err } @@ -131,7 +131,7 @@ func (m *Mock) CreateKey(spec KeySpec) (*KeyInfo, error) { } // newKeyLocked stores a key built from spec and mints its first version. -func (m *Mock) newKeyLocked(spec KeySpec, mode string) *keyData { +func (m *Mock) newKeyLocked(spec *KeySpec, mode string) *keyData { id := m.newOCID(typeKey) k := &keyData{ ID: id, diff --git a/providers/oci/vault/key_test.go b/providers/oci/vault/key_test.go index b836cd5c5..c6e3e1ca4 100644 --- a/providers/oci/vault/key_test.go +++ b/providers/oci/vault/key_test.go @@ -66,10 +66,10 @@ func TestCreateKey(t *testing.T) { t.Run(tc.name, func(t *testing.T) { m := newTestMock() - v, err := m.CreateVault(VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) + v, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) require.NoError(t, err) - info, err := m.CreateKey(KeySpec{ + info, err := m.CreateKey(&KeySpec{ CompartmentID: testCompartment, VaultID: v.ID, DisplayName: "k", @@ -96,7 +96,7 @@ func TestCreateKey(t *testing.T) { func TestCreateKeyRequiresAnActiveVault(t *testing.T) { m := newTestMock() - v, err := m.CreateVault(VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) + v, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) require.NoError(t, err) _, err = m.ScheduleVaultDeletion(v.ID, "") @@ -104,16 +104,16 @@ func TestCreateKeyRequiresAnActiveVault(t *testing.T) { shape := KeyShape{Algorithm: AlgorithmAES, Length: 32} - _, err = m.CreateKey(KeySpec{CompartmentID: testCompartment, VaultID: v.ID, DisplayName: "k", Shape: shape}) + _, 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}) + _, 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}) + _, 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}) + _, err = m.CreateKey(&KeySpec{CompartmentID: testCompartment, VaultID: v.ID, Shape: shape}) assert.Equal(t, cerrors.InvalidArgument, cerrors.GetCode(err)) } diff --git a/providers/oci/vault/portable.go b/providers/oci/vault/portable.go index 3c026c63e..efb63c8e8 100644 --- a/providers/oci/vault/portable.go +++ b/providers/oci/vault/portable.go @@ -34,7 +34,7 @@ func (m *Mock) CreateSecret( vaultID, keyID := m.defaultVaultLocked() - spec := SecretSpec{ + spec := &SecretSpec{ VaultID: vaultID, KeyID: keyID, Name: cfg.Name, @@ -63,7 +63,7 @@ func (m *Mock) DeleteSecret(_ context.Context, name string) error { return cerrors.Newf(cerrors.NotFound, "secret %q not found", name) } - return m.scheduleSecretLocked(s, m.earliestDeletion(minSecretDeletionDays)) + return scheduleSecret(s, m.earliestDeletion(minSecretDeletionDays)) } // GetSecret retrieves secret metadata by name. @@ -163,8 +163,8 @@ func (m *Mock) defaultVaultLocked() (vaultID, keyID string) { return m.defaultVaultID, m.defaultKeyID } - v := m.newVaultLocked(VaultSpec{DisplayName: defaultVaultName}, VaultTypeDefault) - k := m.newKeyLocked(KeySpec{ + v := m.newVaultLocked(&VaultSpec{DisplayName: defaultVaultName}, VaultTypeDefault) + k := m.newKeyLocked(&KeySpec{ VaultID: v.ID, DisplayName: defaultKeyName, Shape: KeyShape{Algorithm: AlgorithmAES, Length: 32}, diff --git a/providers/oci/vault/secret.go b/providers/oci/vault/secret.go index 745875343..45374f003 100644 --- a/providers/oci/vault/secret.go +++ b/providers/oci/vault/secret.go @@ -71,7 +71,7 @@ type secretData struct { } // CreateOCISecret creates a secret and its first version in a vault. -func (m *Mock) CreateOCISecret(spec SecretSpec) (*SecretInfo, error) { +func (m *Mock) CreateOCISecret(spec *SecretSpec) (*SecretInfo, error) { m.mu.Lock() defer m.mu.Unlock() @@ -88,7 +88,7 @@ func (m *Mock) CreateOCISecret(spec SecretSpec) (*SecretInfo, error) { // validateSecretSpecLocked checks a create against the vault, the key and the // names already taken. -func (m *Mock) validateSecretSpecLocked(spec SecretSpec) error { +func (m *Mock) validateSecretSpecLocked(spec *SecretSpec) error { if spec.Name == "" { return cerrors.New(cerrors.InvalidArgument, "secretName is required") } @@ -101,7 +101,7 @@ func (m *Mock) validateSecretSpecLocked(spec SecretSpec) error { return cerrors.New(cerrors.InvalidArgument, "keyId is required") } - if _, err := m.activeVaultLocked(spec.VaultID); err != nil { + if err := m.requireActiveVaultLocked(spec.VaultID); err != nil { return err } @@ -123,7 +123,7 @@ func (m *Mock) validateSecretSpecLocked(spec SecretSpec) error { // newSecretLocked stores a secret built from spec, with its first version // staged CURRENT. -func (m *Mock) newSecretLocked(spec SecretSpec) *secretData { +func (m *Mock) newSecretLocked(spec *SecretSpec) *secretData { id := m.newOCID(typeSecret) s := &secretData{ ID: id, @@ -202,7 +202,7 @@ func (m *Mock) ListOCISecrets(compartmentID, vaultID, name string) ([]SecretInfo // 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) { +func (m *Mock) UpdateOCISecret(id string, upd *SecretUpdate) (*SecretInfo, error) { m.mu.Lock() defer m.mu.Unlock() @@ -240,7 +240,7 @@ func (m *Mock) UpdateOCISecret(id string, upd SecretUpdate) (*SecretInfo, error) // applySecretVersionUpdateLocked adds the version an update carries and // promotes the one it names. -func (m *Mock) applySecretVersionUpdateLocked(s *secretData, upd SecretUpdate) error { +func (m *Mock) applySecretVersionUpdateLocked(s *secretData, upd *SecretUpdate) error { if upd.ContentGiven { stage, err := newVersionStage(upd.Stage) if err != nil { @@ -256,7 +256,7 @@ func (m *Mock) applySecretVersionUpdateLocked(s *secretData, upd SecretUpdate) e } if upd.CurrentVersionNumber != nil { - return m.promoteVersionLocked(s, *upd.CurrentVersionNumber) + return promoteVersion(s, *upd.CurrentVersionNumber) } return nil @@ -285,7 +285,7 @@ func (m *Mock) rekeyLocked(s *secretData, keyID string) error { // 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 cancelled. +// 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() @@ -300,7 +300,7 @@ func (m *Mock) ScheduleOCISecretDeletion(id, at string) (*SecretInfo, error) { return nil, err } - if err := m.scheduleSecretLocked(s, when); err != nil { + if err := scheduleSecret(s, when); err != nil { return nil, err } @@ -309,8 +309,8 @@ func (m *Mock) ScheduleOCISecretDeletion(id, at string) (*SecretInfo, error) { return &info, nil } -// scheduleSecretLocked moves a secret into PENDING_DELETION. -func (m *Mock) scheduleSecretLocked(s *secretData, when string) error { +// 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) } @@ -394,7 +394,7 @@ func (m *Mock) secretLocked(id string) (*secretData, error) { // secretByNameLocked finds a live secret by name. 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 cancelled. +// pending secret stays reachable by OCID until its deletion is canceled. func (m *Mock) secretByNameLocked(name string) (*secretData, bool) { for _, s := range m.secrets.SortedValues() { if s.Name == name && s.LifecycleState == StateActive { diff --git a/providers/oci/vault/secret_test.go b/providers/oci/vault/secret_test.go index d3d6fcd46..337212690 100644 --- a/providers/oci/vault/secret_test.go +++ b/providers/oci/vault/secret_test.go @@ -14,7 +14,7 @@ func TestCreateOCISecret(t *testing.T) { m := newTestMock() vaultID, keyID := newVaultAndKey(t, m, testCompartment) - info, err := m.CreateOCISecret(SecretSpec{ + info, err := m.CreateOCISecret(&SecretSpec{ CompartmentID: testCompartment, VaultID: vaultID, KeyID: keyID, @@ -39,50 +39,50 @@ func TestCreateOCISecretRejections(t *testing.T) { vaultID, keyID := newVaultAndKey(t, m, testCompartment) otherVault, otherKey := newVaultAndKey(t, m, otherCompart) - base := SecretSpec{CompartmentID: testCompartment, VaultID: vaultID, KeyID: keyID, Name: "s"} + base := &SecretSpec{CompartmentID: testCompartment, VaultID: vaultID, KeyID: keyID, Name: "s"} _, err := m.CreateOCISecret(base) require.NoError(t, err) tests := []struct { name string - spec SecretSpec + spec *SecretSpec expect cerrors.Code }{ {name: "duplicate name", spec: base, expect: cerrors.AlreadyExists}, { name: "no name", - spec: SecretSpec{CompartmentID: testCompartment, VaultID: vaultID, KeyID: keyID}, + spec: &SecretSpec{CompartmentID: testCompartment, VaultID: vaultID, KeyID: keyID}, expect: cerrors.InvalidArgument, }, { name: "no vault", - spec: SecretSpec{CompartmentID: testCompartment, KeyID: keyID, Name: "x"}, + spec: &SecretSpec{CompartmentID: testCompartment, KeyID: keyID, Name: "x"}, expect: cerrors.InvalidArgument, }, { name: "no key", - spec: SecretSpec{CompartmentID: testCompartment, VaultID: vaultID, Name: "x"}, + 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"}, + 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"}, + 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"}, + 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"}, + spec: &SecretSpec{CompartmentID: otherCompart, VaultID: otherVault, KeyID: otherKey, Name: "x"}, expect: cerrors.OK, }, } @@ -164,7 +164,7 @@ func TestSecretScheduledDeletionAndCancellation(t *testing.T) { assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) // A secret pending deletion cannot be updated. - _, err = m.UpdateOCISecret(s.ID, SecretUpdate{}) + _, err = m.UpdateOCISecret(s.ID, &SecretUpdate{}) assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) restored, err := m.CancelOCISecretDeletion(s.ID) @@ -185,7 +185,7 @@ func TestCancelDeletionRefusesAReusedName(t *testing.T) { _, err := m.ScheduleOCISecretDeletion(first.ID, "") require.NoError(t, err) - second, err := m.CreateOCISecret(SecretSpec{ + second, err := m.CreateOCISecret(&SecretSpec{ CompartmentID: testCompartment, VaultID: first.VaultID, KeyID: first.KeyID, @@ -205,7 +205,7 @@ func TestUpdateOCISecret(t *testing.T) { desc := "now described" - got, err := m.UpdateOCISecret(s.ID, SecretUpdate{ + got, err := m.UpdateOCISecret(s.ID, &SecretUpdate{ Description: &desc, FreeformTags: map[string]string{"k": "v"}, }) @@ -214,7 +214,7 @@ func TestUpdateOCISecret(t *testing.T) { assert.Equal(t, "v", got.FreeformTags["k"]) assert.Equal(t, int64(1), got.CurrentVersionNumber) - _, err = m.UpdateOCISecret("ocid1.vaultsecret.oc1.iad.x", SecretUpdate{}) + _, err = m.UpdateOCISecret("ocid1.vaultsecret.oc1.iad.x", &SecretUpdate{}) assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) } @@ -222,7 +222,7 @@ func TestUpdateOCISecretRekeys(t *testing.T) { m := newTestMock() s := newSecret(t, m, testCompartment, "rekey", "a") - second, err := m.CreateKey(KeySpec{ + second, err := m.CreateKey(&KeySpec{ CompartmentID: testCompartment, VaultID: s.VaultID, DisplayName: "k2", @@ -230,16 +230,16 @@ func TestUpdateOCISecretRekeys(t *testing.T) { }) require.NoError(t, err) - got, err := m.UpdateOCISecret(s.ID, SecretUpdate{KeyID: second.ID}) + 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}) + _, 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"}) + _, err = m.UpdateOCISecret(s.ID, &SecretUpdate{KeyID: "ocid1.key.oc1.iad.x"}) assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) } diff --git a/providers/oci/vault/vault.go b/providers/oci/vault/vault.go index 211450275..30a67373c 100644 --- a/providers/oci/vault/vault.go +++ b/providers/oci/vault/vault.go @@ -61,6 +61,8 @@ const ( ) // 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 @@ -77,6 +79,8 @@ type Update struct { } // VaultInfo describes a vault. +// +//nolint:revive // Info alone would not distinguish it from KeyInfo and SecretInfo. type VaultInfo struct { ID string CompartmentID string @@ -147,7 +151,7 @@ func (m *Mock) now() string { // 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) { +func (m *Mock) CreateVault(spec *VaultSpec) (*VaultInfo, error) { m.mu.Lock() defer m.mu.Unlock() @@ -173,7 +177,7 @@ func (m *Mock) CreateVault(spec VaultSpec) (*VaultInfo, error) { } // newVaultLocked stores a vault built from spec. -func (m *Mock) newVaultLocked(spec VaultSpec, vaultType string) *vaultData { +func (m *Mock) newVaultLocked(spec *VaultSpec, vaultType string) *vaultData { id := m.newOCID(typeVault) v := &vaultData{ ID: id, @@ -250,7 +254,7 @@ func (m *Mock) UpdateVault(id string, upd Update) (*VaultInfo, error) { // 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 cancelled. +// deletion is canceled. func (m *Mock) ScheduleVaultDeletion(id, at string) (*VaultInfo, error) { m.mu.Lock() defer m.mu.Unlock() @@ -343,18 +347,19 @@ func (m *Mock) vaultLocked(id string) (*vaultData, error) { return v, nil } -// activeVaultLocked reads a vault that can still take new keys and secrets. -func (m *Mock) activeVaultLocked(id string) (*vaultData, error) { +// 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 nil, err + return err } if v.LifecycleState != StateActive { - return nil, cerrors.Newf(cerrors.FailedPrecondition, "vault %s is %s", id, v.LifecycleState) + return cerrors.Newf(cerrors.FailedPrecondition, "vault %s is %s", id, v.LifecycleState) } - return v, nil + return nil } // deletionTime validates a caller-supplied deletion time against OCI's window diff --git a/providers/oci/vault/vault_test.go b/providers/oci/vault/vault_test.go index 11207ded0..f712f0041 100644 --- a/providers/oci/vault/vault_test.go +++ b/providers/oci/vault/vault_test.go @@ -31,10 +31,10 @@ func newTestMock() *Mock { func newVaultAndKey(t *testing.T, m *Mock, compartmentID string) (vaultID, keyID string) { t.Helper() - v, err := m.CreateVault(VaultSpec{CompartmentID: compartmentID, DisplayName: "v"}) + v, err := m.CreateVault(&VaultSpec{CompartmentID: compartmentID, DisplayName: "v"}) require.NoError(t, err) - k, err := m.CreateKey(KeySpec{ + k, err := m.CreateKey(&KeySpec{ CompartmentID: compartmentID, VaultID: v.ID, DisplayName: "k", @@ -50,7 +50,7 @@ func newSecret(t *testing.T, m *Mock, compartmentID, name, value string) *Secret vaultID, keyID := newVaultAndKey(t, m, compartmentID) - s, err := m.CreateOCISecret(SecretSpec{ + s, err := m.CreateOCISecret(&SecretSpec{ CompartmentID: compartmentID, VaultID: vaultID, KeyID: keyID, @@ -65,28 +65,28 @@ func newSecret(t *testing.T, m *Mock, compartmentID, name, value string) *Secret func TestCreateVault(t *testing.T) { tests := []struct { name string - spec VaultSpec + spec *VaultSpec expectErr cerrors.Code expectType string }{ { name: "defaults to a DEFAULT vault", - spec: VaultSpec{CompartmentID: testCompartment, DisplayName: "v1"}, + spec: &VaultSpec{CompartmentID: testCompartment, DisplayName: "v1"}, expectType: VaultTypeDefault, }, { name: "virtual private", - spec: VaultSpec{CompartmentID: testCompartment, DisplayName: "v2", VaultType: VaultTypeVirtualPrivate}, + spec: &VaultSpec{CompartmentID: testCompartment, DisplayName: "v2", VaultType: VaultTypeVirtualPrivate}, expectType: VaultTypeVirtualPrivate, }, { name: "display name required", - spec: VaultSpec{CompartmentID: testCompartment}, + spec: &VaultSpec{CompartmentID: testCompartment}, expectErr: cerrors.InvalidArgument, }, { name: "unknown vault type", - spec: VaultSpec{CompartmentID: testCompartment, DisplayName: "v3", VaultType: "SUPER_PRIVATE"}, + spec: &VaultSpec{CompartmentID: testCompartment, DisplayName: "v3", VaultType: "SUPER_PRIVATE"}, expectErr: cerrors.InvalidArgument, }, } @@ -124,10 +124,10 @@ func TestVaultNotFound(t *testing.T) { func TestListVaultsFiltersByCompartment(t *testing.T) { m := newTestMock() - _, err := m.CreateVault(VaultSpec{CompartmentID: testCompartment, DisplayName: "mine"}) + _, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "mine"}) require.NoError(t, err) - _, err = m.CreateVault(VaultSpec{CompartmentID: otherCompart, DisplayName: "theirs"}) + _, err = m.CreateVault(&VaultSpec{CompartmentID: otherCompart, DisplayName: "theirs"}) require.NoError(t, err) mine, err := m.ListVaults(testCompartment) @@ -148,7 +148,7 @@ func TestListVaultsFiltersByCompartment(t *testing.T) { func TestUpdateVault(t *testing.T) { m := newTestMock() - v, err := m.CreateVault(VaultSpec{CompartmentID: testCompartment, DisplayName: "before"}) + v, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "before"}) require.NoError(t, err) name := "after" @@ -165,7 +165,7 @@ func TestUpdateVault(t *testing.T) { func TestVaultScheduledDeletionAndCancellation(t *testing.T) { m := newTestMock() - v, err := m.CreateVault(VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) + v, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) require.NoError(t, err) scheduled, err := m.ScheduleVaultDeletion(v.ID, "") @@ -206,7 +206,7 @@ func TestVaultDeletionWindow(t *testing.T) { t.Run(tc.name, func(t *testing.T) { m := newTestMock() - v, err := m.CreateVault(VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) + v, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) require.NoError(t, err) got, err := m.ScheduleVaultDeletion(v.ID, tc.at) @@ -226,7 +226,7 @@ func TestVaultDeletionWindow(t *testing.T) { func TestChangeVaultCompartment(t *testing.T) { m := newTestMock() - v, err := m.CreateVault(VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) + v, err := m.CreateVault(&VaultSpec{CompartmentID: testCompartment, DisplayName: "v"}) require.NoError(t, err) require.NoError(t, m.ChangeVaultCompartment(v.ID, otherCompart)) diff --git a/providers/oci/vault/version.go b/providers/oci/vault/version.go index a09f2dbe8..f5ca3ad89 100644 --- a/providers/oci/vault/version.go +++ b/providers/oci/vault/version.go @@ -315,9 +315,9 @@ func (m *Mock) addVersionLocked(s *secretData, content []byte, name, stage strin return v } -// promoteVersionLocked makes an existing version the CURRENT one, which is how -// OCI finishes a rotation staged as PENDING. -func (m *Mock) promoteVersionLocked(s *secretData, number int64) error { +// 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 diff --git a/providers/oci/vault/version_test.go b/providers/oci/vault/version_test.go index 55586e5e6..26842e33d 100644 --- a/providers/oci/vault/version_test.go +++ b/providers/oci/vault/version_test.go @@ -13,7 +13,7 @@ import ( func addVersion(t *testing.T, m *Mock, secretID, value, name, stage string) { t.Helper() - _, err := m.UpdateOCISecret(secretID, SecretUpdate{ + _, err := m.UpdateOCISecret(secretID, &SecretUpdate{ Content: []byte(value), ContentName: name, Stage: stage, @@ -97,7 +97,7 @@ func TestPromotingAPendingVersionFinishesRotation(t *testing.T) { two := int64(2) - got, err := m.UpdateOCISecret(s.ID, SecretUpdate{CurrentVersionNumber: &two}) + got, err := m.UpdateOCISecret(s.ID, &SecretUpdate{CurrentVersionNumber: &two}) require.NoError(t, err) assert.Equal(t, int64(2), got.CurrentVersionNumber) @@ -111,11 +111,11 @@ func TestPromotingAPendingVersionFinishesRotation(t *testing.T) { 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}) + _, err = m.UpdateOCISecret(s.ID, &SecretUpdate{CurrentVersionNumber: &two}) require.NoError(t, err) missing := int64(99) - _, err = m.UpdateOCISecret(s.ID, SecretUpdate{CurrentVersionNumber: &missing}) + _, err = m.UpdateOCISecret(s.ID, &SecretUpdate{CurrentVersionNumber: &missing}) assert.Equal(t, cerrors.NotFound, cerrors.GetCode(err)) } @@ -124,7 +124,7 @@ func TestNewVersionStageIsRestricted(t *testing.T) { 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}) + _, 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) } @@ -136,7 +136,7 @@ func TestVersionNamesAreUniquePerSecret(t *testing.T) { addVersion(t, m, s.ID, "b", "release-1", StageCurrent) - _, err := m.UpdateOCISecret(s.ID, SecretUpdate{ + _, err := m.UpdateOCISecret(s.ID, &SecretUpdate{ Content: []byte("c"), ContentName: "release-1", ContentGiven: true, @@ -242,7 +242,7 @@ func TestSecretVersionScheduledDeletionAndCancellation(t *testing.T) { // A version pending deletion cannot be promoted back to CURRENT. one := int64(1) - _, err = m.UpdateOCISecret(s.ID, SecretUpdate{CurrentVersionNumber: &one}) + _, err = m.UpdateOCISecret(s.ID, &SecretUpdate{CurrentVersionNumber: &one}) assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) restored, err := m.CancelSecretVersionDeletion(s.ID, 1) diff --git a/server/oci/vault/bundle.go b/server/oci/vault/bundle.go index 11bf89b39..bb27e5b60 100644 --- a/server/oci/vault/bundle.go +++ b/server/oci/vault/bundle.go @@ -13,7 +13,7 @@ import ( // 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(0) != segSecretBundles { + if rt.seg(idxCollection) != segSecretBundles { notFound(w, r) return } @@ -24,12 +24,12 @@ func (h *Handler) serveBundles(w http.ResponseWriter, r *http.Request, rt route) } switch { - case rt.count() == lenSub && rt.seg(1) == segActions: - h.getBundleByName(w, r, rt.seg(2)) + 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(1)) - case rt.count() == lenSub && rt.seg(2) == segVersions: - h.listBundleVersions(w, r, rt.seg(1)) + 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) } diff --git a/server/oci/vault/extras.go b/server/oci/vault/extras.go index 18bffd4be..01e01d763 100644 --- a/server/oci/vault/extras.go +++ b/server/oci/vault/extras.go @@ -4,10 +4,13 @@ import ( vaultprovider "github.com/stackshy/cloudemu/v2/providers/oci/vault" ) -// VaultManagement is the KMS vault surface. The portable secrets driver has no +// VaultManagement is the KMS vault surface. +// // 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. The portable secrets driver has no type VaultManagement interface { - CreateVault(spec vaultprovider.VaultSpec) (*vaultprovider.VaultInfo, error) + 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) @@ -22,7 +25,7 @@ type VaultManagement interface { // 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) + 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) @@ -42,11 +45,11 @@ type KeyManagement interface { // 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) + 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) + 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 diff --git a/server/oci/vault/handler.go b/server/oci/vault/handler.go index 6ccf1fd8a..9c8813c61 100644 --- a/server/oci/vault/handler.go +++ b/server/oci/vault/handler.go @@ -36,7 +36,7 @@ // // 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 cancelled. +// until the deletion is canceled. package vault import ( @@ -108,6 +108,16 @@ const ( 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 @@ -155,10 +165,10 @@ func (*Handler) Matches(r *http.Request) bool { } if rt.Version == apiVersionBundles { - return rt.seg(0) == segSecretBundles + return rt.seg(idxCollection) == segSecretBundles } - switch rt.seg(0) { + switch rt.seg(idxCollection) { case segVaults, segKeys, segSecrets, segEncrypt, segDecrypt, segGenerate, segSign, segVerify, segExportKey: return true @@ -187,7 +197,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - switch rt.seg(0) { + switch rt.seg(idxCollection) { case segVaults: h.serveVaults(w, r, rt) case segKeys: @@ -195,7 +205,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case segSecrets: h.serveSecrets(w, r, rt) default: - unemulatedCrypto(w, r, rt.seg(0)) + unemulatedCrypto(w, r, rt.seg(idxCollection)) } } @@ -229,7 +239,7 @@ func parsePath(urlPath string) (route, bool) { // isAction reports whether rt addresses /{collection}/{id}/actions/{action}. func isAction(rt route) bool { - return rt.count() == lenSubID && rt.seg(2) == segActions + return rt.count() == lenSubID && rt.seg(idxSub) == segActions } // methodNotAllowed is the response for a verb a collection does not serve. @@ -278,6 +288,31 @@ func decodeDeletion(w http.ResponseWriter, r *http.Request) (string, bool) { 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 @@ -309,7 +344,7 @@ func rejectDefinedTags(w http.ResponseWriter, r *http.Request, tags definedTags) } // rejectUnmodelled refuses a request naming a field the emulator has no -// behaviour for, naming the field rather than dropping it. +// 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 diff --git a/server/oci/vault/handler_test.go b/server/oci/vault/handler_test.go index 0193bd866..d9f7901e2 100644 --- a/server/oci/vault/handler_test.go +++ b/server/oci/vault/handler_test.go @@ -234,9 +234,9 @@ func TestVaultLifecycleOverTheWire(t *testing.T) { assert.Equal(t, "PENDING_DELETION", decode(t, scheduled)["lifecycleState"]) assert.NotEmpty(t, scheduled.Header().Get(ocirest.HeaderWorkRequestID)) - cancelled := f.do(http.MethodPost, "/20180608/vaults/"+id+"/actions/cancelDeletion", nil) - require.Equal(t, http.StatusOK, cancelled.Code) - assert.Equal(t, "ACTIVE", decode(t, cancelled)["lifecycleState"]) + 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) { @@ -355,9 +355,9 @@ func TestKeyLifecycleAndRotationOverTheWire(t *testing.T) { require.Equal(t, http.StatusOK, scheduled.Code, scheduled.Body.String()) assert.Equal(t, "PENDING_DELETION", decode(t, scheduled)["lifecycleState"]) - cancelled := f.do(http.MethodPost, "/20180608/keys/"+keyID+"/actions/cancelDeletion", nil) - require.Equal(t, http.StatusOK, cancelled.Code) - assert.Equal(t, "ACTIVE", decode(t, cancelled)["lifecycleState"]) + canceled := f.do(http.MethodPost, "/20180608/keys/"+keyID+"/actions/cancelDeletion", nil) + require.Equal(t, http.StatusOK, canceled.Code) + assert.Equal(t, "ACTIVE", decode(t, canceled)["lifecycleState"]) } func TestKeyErrors(t *testing.T) { @@ -494,8 +494,8 @@ func TestSecretScheduledDeletionOverTheWire(t *testing.T) { again := f.do(http.MethodPost, "/20180608/secrets/"+secretID+"/actions/scheduleDeletion", nil) assert.Equal(t, http.StatusConflict, again.Code) - cancelled := f.do(http.MethodPost, "/20180608/secrets/"+secretID+"/actions/cancelDeletion", nil) - require.Equal(t, http.StatusNoContent, cancelled.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"]) @@ -523,8 +523,8 @@ func TestSecretVersionScheduledDeletionOverTheWire(t *testing.T) { require.Equal(t, http.StatusNoContent, scheduled.Code, scheduled.Body.String()) assert.NotEmpty(t, scheduled.Header().Get(ocirest.HeaderWorkRequestID)) - cancelled := f.do(http.MethodPost, base+"1/actions/cancelDeletion", nil) - require.Equal(t, http.StatusNoContent, cancelled.Code) + 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) diff --git a/server/oci/vault/key.go b/server/oci/vault/key.go index aefbd3b0c..99938aef9 100644 --- a/server/oci/vault/key.go +++ b/server/oci/vault/key.go @@ -29,14 +29,14 @@ 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(1) != segActions: - h.serveKeyResource(w, r, rt.seg(1)) + case rt.count() == lenResource && rt.seg(idxID) != segActions: + h.serveKeyResource(w, r, rt.seg(idxID)) case isAction(rt): - h.keyAction(w, r, rt.seg(1), rt.seg(3)) - case rt.count() == lenSub && rt.seg(2) == segKeyVersions: - h.serveKeyVersionCollection(w, r, rt.seg(1)) - case rt.count() == lenSubID && rt.seg(2) == segKeyVersions: - h.getKeyVersion(w, r, rt.seg(1), rt.seg(3)) + 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) } @@ -88,7 +88,7 @@ func (h *Handler) keyAction(w http.ResponseWriter, r *http.Request, id, action s case actionCancelDeletion: h.cancelKeyDeletion(w, r, id) case actionChangeCompartment: - h.changeKeyCompartment(w, r, id) + h.changeCompartment(w, r, id, opChangeKeyCompartment, entityKey, h.extras.ChangeKeyCompartment) default: unknownAction(w, r, action) } @@ -101,7 +101,7 @@ func (h *Handler) createKey(w http.ResponseWriter, r *http.Request) { return } - if !h.validKeyRequest(w, r, req) { + if !validKeyRequest(w, r, &req) { return } @@ -115,7 +115,7 @@ func (h *Handler) createKey(w http.ResponseWriter, r *http.Request) { return } - info, err := h.extras.CreateKey(vaultprovider.KeySpec{ + info, err := h.extras.CreateKey(&vaultprovider.KeySpec{ CompartmentID: req.CompartmentID, VaultID: vaultIDOf(r), DisplayName: deref(req.DisplayName), @@ -123,17 +123,12 @@ func (h *Handler) createKey(w http.ResponseWriter, r *http.Request) { ProtectionMode: req.ProtectionMode, FreeformTags: req.FreeformTags, }) - if err != nil { - ocirest.WriteDriverError(w, r, err) - return - } - h.accept(w, opCreateKey, info.CompartmentID, entityKey, workrequest.ActionCreated, info.ID) - ocirest.WriteJSON(w, r, http.StatusOK, toKeyResponse(info)) + h.writeKey(w, r, info, err, opCreateKey, workrequest.ActionCreated) } // validKeyRequest refuses the key inputs CloudEmu does not model. -func (h *Handler) validKeyRequest(w http.ResponseWriter, r *http.Request, req keyRequest) bool { +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) && @@ -172,7 +167,7 @@ func (h *Handler) updateKey(w http.ResponseWriter, r *http.Request, id string) { return } - if !h.validKeyRequest(w, r, req) { + if !validKeyRequest(w, r, &req) { return } @@ -187,13 +182,8 @@ func (h *Handler) updateKey(w http.ResponseWriter, r *http.Request, id string) { DisplayName: req.DisplayName, FreeformTags: req.FreeformTags, }) - if err != nil { - ocirest.WriteDriverError(w, r, err) - return - } - h.accept(w, opUpdateKey, info.CompartmentID, entityKey, workrequest.ActionUpdated, info.ID) - ocirest.WriteJSON(w, r, http.StatusOK, toKeyResponse(info)) + h.writeKey(w, r, info, err, opUpdateKey, workrequest.ActionUpdated) } func (h *Handler) scheduleKeyDeletion(w http.ResponseWriter, r *http.Request, id string) { @@ -203,44 +193,28 @@ func (h *Handler) scheduleKeyDeletion(w http.ResponseWriter, r *http.Request, id } info, err := h.extras.ScheduleKeyDeletion(id, at) - if err != nil { - ocirest.WriteDriverError(w, r, err) - return - } - h.accept(w, opScheduleKeyDeletion, info.CompartmentID, entityKey, workrequest.ActionUpdated, info.ID) - ocirest.WriteJSON(w, r, http.StatusOK, toKeyResponse(info)) + 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) - if err != nil { - ocirest.WriteDriverError(w, r, err) - return - } - h.accept(w, opCancelKeyDeletion, info.CompartmentID, entityKey, workrequest.ActionUpdated, info.ID) - ocirest.WriteJSON(w, r, http.StatusOK, toKeyResponse(info)) + h.writeKey(w, r, info, err, opCancelKeyDeletion, workrequest.ActionUpdated) } -func (h *Handler) changeKeyCompartment(w http.ResponseWriter, r *http.Request, id string) { - 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 := h.extras.ChangeKeyCompartment(id, compartmentID); err != nil { +// 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, opChangeKeyCompartment, compartmentID, entityKey, workrequest.ActionUpdated, id) - ocirest.WriteJSON(w, r, http.StatusAccepted, nil) + 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 diff --git a/server/oci/vault/secret.go b/server/oci/vault/secret.go index ce92e4ee1..bb3845578 100644 --- a/server/oci/vault/secret.go +++ b/server/oci/vault/secret.go @@ -26,23 +26,37 @@ const ( entitySecretVersion = "secretversion" ) -// serveSecrets routes the secret collection, its versions and their actions. +// 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() == lenResource && rt.seg(1) != segActions: - h.serveSecretResource(w, r, rt.seg(1)) - case rt.count() == lenSub && rt.seg(1) == segActions: - h.secretCollectionAction(w, r, rt.seg(2)) + 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(1), rt.seg(3)) - case rt.count() == lenSub && rt.seg(2) == segVersions: - h.listSecretVersions(w, r, rt.seg(1)) - case rt.count() == lenSubID && rt.seg(2) == segVersions: - h.getSecretVersion(w, r, rt.seg(1), rt.seg(3)) - case rt.count() == lenSubAction && rt.seg(2) == segVersions && rt.seg(4) == segActions: - h.secretVersionAction(w, r, rt.seg(1), rt.seg(3), rt.seg(5)) + 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) } @@ -105,7 +119,7 @@ func (h *Handler) secretAction(w http.ResponseWriter, r *http.Request, id, actio case actionCancelDeletion: h.cancelSecretDeletion(w, r, id) case actionChangeCompartment: - h.changeSecretCompartment(w, r, id) + h.changeCompartment(w, r, id, opChangeSecretCompartment, entitySecret, h.extras.ChangeSecretCompartment) default: unknownAction(w, r, action) } @@ -118,7 +132,7 @@ func (h *Handler) createSecret(w http.ResponseWriter, r *http.Request) { return } - if !h.validSecretRequest(w, r, req) { + if !validSecretRequest(w, r, &req) { return } @@ -137,7 +151,7 @@ func (h *Handler) createSecret(w http.ResponseWriter, r *http.Request) { return } - info, err := h.extras.CreateOCISecret(vaultprovider.SecretSpec{ + info, err := h.extras.CreateOCISecret(&vaultprovider.SecretSpec{ CompartmentID: req.CompartmentID, VaultID: req.VaultID, KeyID: req.KeyID, @@ -156,8 +170,22 @@ func (h *Handler) createSecret(w http.ResponseWriter, r *http.Request) { ocirest.WriteJSON(w, r, http.StatusOK, toSecretResponse(info)) } +// writeSecretAction records the work request for a secret mutation OCI answers +// with headers only. +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 (h *Handler) validSecretRequest(w http.ResponseWriter, r *http.Request, req secretRequest) bool { +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) && @@ -196,11 +224,11 @@ func (h *Handler) updateSecret(w http.ResponseWriter, r *http.Request, id string return } - if !h.validSecretRequest(w, r, req) { + if !validSecretRequest(w, r, &req) { return } - upd := vaultprovider.SecretUpdate{ + upd := &vaultprovider.SecretUpdate{ Description: req.Description, KeyID: req.KeyID, CurrentVersionNumber: req.CurrentVersionNumber, @@ -238,44 +266,14 @@ func (h *Handler) scheduleSecretDeletion(w http.ResponseWriter, r *http.Request, } info, err := h.extras.ScheduleOCISecretDeletion(id, at) - if err != nil { - ocirest.WriteDriverError(w, r, err) - return - } - h.accept(w, opScheduleSecretDeletion, info.CompartmentID, entitySecret, workrequest.ActionUpdated, info.ID) - ocirest.WriteJSON(w, r, http.StatusNoContent, nil) + 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) - if err != nil { - ocirest.WriteDriverError(w, r, err) - return - } - - h.accept(w, opCancelSecretDeletion, info.CompartmentID, entitySecret, workrequest.ActionUpdated, info.ID) - ocirest.WriteJSON(w, r, http.StatusNoContent, nil) -} - -func (h *Handler) changeSecretCompartment(w http.ResponseWriter, r *http.Request, id string) { - 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 := h.extras.ChangeSecretCompartment(id, compartmentID); err != nil { - ocirest.WriteDriverError(w, r, err) - return - } - h.accept(w, opChangeSecretCompartment, compartmentID, entitySecret, workrequest.ActionUpdated, id) - ocirest.WriteJSON(w, r, http.StatusAccepted, nil) + h.writeSecretAction(w, r, info, err, opCancelSecretDeletion) } // listSecretVersions lists a secret's versions. Real OCI takes no diff --git a/server/oci/vault/vault.go b/server/oci/vault/vault.go index 06cf0c415..d1bd888c1 100644 --- a/server/oci/vault/vault.go +++ b/server/oci/vault/vault.go @@ -10,10 +10,10 @@ import ( // Work request operations the vault mutations record. const ( - opCreateVault = "CREATE_VAULT" - opUpdateVault = "UPDATE_VAULT" - opScheduleVaultDeletion = "SCHEDULE_VAULT_DELETION" - opCancelVaultDeletion = "CANCEL_VAULT_DELETION" + opCreateVault = "CREATE_VAULT" + opUpdateVault = "UPDATE_VAULT" + opScheduleVaultDeletion = "SCHEDULE_VAULT_DELETION" + opCancelVaultDeletion = "CANCEL_VAULT_DELETION" opChangeVaultCompartment = "CHANGE_VAULT_COMPARTMENT" ) @@ -25,10 +25,10 @@ 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(1) != segActions: - h.serveVaultResource(w, r, rt.seg(1)) + case rt.count() == lenResource && rt.seg(idxID) != segActions: + h.serveVaultResource(w, r, rt.seg(idxID)) case isAction(rt): - h.vaultAction(w, r, rt.seg(1), rt.seg(3)) + h.vaultAction(w, r, rt.seg(idxID), rt.seg(idxSubID)) default: notFound(w, r) } @@ -70,7 +70,7 @@ func (h *Handler) vaultAction(w http.ResponseWriter, r *http.Request, id, action case actionCancelDeletion: h.cancelVaultDeletion(w, r, id) case actionChangeCompartment: - h.changeVaultCompartment(w, r, id) + h.changeCompartment(w, r, id, opChangeVaultCompartment, entityVault, h.extras.ChangeVaultCompartment) default: unknownAction(w, r, action) } @@ -83,7 +83,7 @@ func (h *Handler) createVault(w http.ResponseWriter, r *http.Request) { return } - if !h.validVaultRequest(w, r, req) { + if !validVaultRequest(w, r, &req) { return } @@ -92,23 +92,18 @@ func (h *Handler) createVault(w http.ResponseWriter, r *http.Request) { return } - info, err := h.extras.CreateVault(vaultprovider.VaultSpec{ + info, err := h.extras.CreateVault(&vaultprovider.VaultSpec{ CompartmentID: req.CompartmentID, DisplayName: deref(req.DisplayName), VaultType: req.VaultType, FreeformTags: req.FreeformTags, }) - if err != nil { - ocirest.WriteDriverError(w, r, err) - return - } - h.accept(w, opCreateVault, info.CompartmentID, entityVault, workrequest.ActionCreated, info.ID) - ocirest.WriteJSON(w, r, http.StatusOK, toVaultResponse(info)) + h.writeVault(w, r, info, err, opCreateVault, workrequest.ActionCreated) } // validVaultRequest refuses the vault inputs CloudEmu does not model. -func (h *Handler) validVaultRequest(w http.ResponseWriter, r *http.Request, req vaultRequest) bool { +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) && @@ -147,7 +142,7 @@ func (h *Handler) updateVault(w http.ResponseWriter, r *http.Request, id string) return } - if !h.validVaultRequest(w, r, req) { + if !validVaultRequest(w, r, &req) { return } @@ -155,13 +150,8 @@ func (h *Handler) updateVault(w http.ResponseWriter, r *http.Request, id string) DisplayName: req.DisplayName, FreeformTags: req.FreeformTags, }) - if err != nil { - ocirest.WriteDriverError(w, r, err) - return - } - h.accept(w, opUpdateVault, info.CompartmentID, entityVault, workrequest.ActionUpdated, info.ID) - ocirest.WriteJSON(w, r, http.StatusOK, toVaultResponse(info)) + h.writeVault(w, r, info, err, opUpdateVault, workrequest.ActionUpdated) } func (h *Handler) scheduleVaultDeletion(w http.ResponseWriter, r *http.Request, id string) { @@ -171,44 +161,28 @@ func (h *Handler) scheduleVaultDeletion(w http.ResponseWriter, r *http.Request, } info, err := h.extras.ScheduleVaultDeletion(id, at) - if err != nil { - ocirest.WriteDriverError(w, r, err) - return - } - h.accept(w, opScheduleVaultDeletion, info.CompartmentID, entityVault, workrequest.ActionUpdated, info.ID) - ocirest.WriteJSON(w, r, http.StatusOK, toVaultResponse(info)) + 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) - if err != nil { - ocirest.WriteDriverError(w, r, err) - return - } - h.accept(w, opCancelVaultDeletion, info.CompartmentID, entityVault, workrequest.ActionUpdated, info.ID) - ocirest.WriteJSON(w, r, http.StatusOK, toVaultResponse(info)) + h.writeVault(w, r, info, err, opCancelVaultDeletion, workrequest.ActionUpdated) } -func (h *Handler) changeVaultCompartment(w http.ResponseWriter, r *http.Request, id string) { - 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 := h.extras.ChangeVaultCompartment(id, compartmentID); err != nil { +// 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, opChangeVaultCompartment, compartmentID, entityVault, workrequest.ActionUpdated, id) - ocirest.WriteJSON(w, r, http.StatusAccepted, nil) + h.accept(w, operation, info.CompartmentID, entityVault, actionType, info.ID) + ocirest.WriteJSON(w, r, http.StatusOK, toVaultResponse(info)) } func toVaultResponse(info *vaultprovider.VaultInfo) vaultResponse { From 73c671906a226aea7edd99ddcd8d0d96340fd5b2 Mon Sep 17 00:00:00 2001 From: arunesh-j Date: Mon, 7 Sep 2026 22:38:22 +0530 Subject: [PATCH 3/3] fix(oci): per-vault secret names, KMS ENABLED state, and Vault persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review findings on the OCI Vault branch. Snapshot: providers/oci/vault had no snapshot.go, so vault.Mock was the only providers/oci package that was not Snapshottable and TestSnapshotCompleteness failed. Add it over all four stores, carrying the portable driver's default vault and key so a restored mock does not mint a second one and orphan the secrets it just restored. KMS lifecycle: keys and key versions reported ACTIVE. Per the SDK's KeyLifecycleStateEnum they are ENABLED and never ACTIVE — ACTIVE belongs to vaults and secrets — and terraform-provider-oci's oci_kms_key waits on ENABLED, so an ACTIVE key hangs the apply. Secret names: uniqueness was enforced across every vault, so the same name in two vaults was refused and CancelOCISecretDeletion could wrongly refuse a restore. Scope it to the vault, as OCI does. The portable driver keys by bare name, so it now resolves across vaults and rejects an ambiguous name naming both, rather than silently picking one; its create refuses a name another vault already holds. getByName prefers the live secret over a namesake pending deletion. Also document the Vault surface in docs/services.md, repair the garbled VaultManagement doc comment, record why a secret's deletion actions answer 204 where a key's and a vault's answer 200, and raise coverage on both changed packages (providers 96.9%, wire 79.7% -> 94.1%). --- docs/coverage/README.md | 2 +- docs/coverage/oci/README.md | 1 + docs/services.md | 131 ++++++- providers/oci/vault/key.go | 8 +- providers/oci/vault/key_test.go | 42 +- providers/oci/vault/portable.go | 78 +++- providers/oci/vault/portable_test.go | 114 ++++++ providers/oci/vault/race_test.go | 9 +- providers/oci/vault/secret.go | 38 +- providers/oci/vault/secret_test.go | 85 ++++ providers/oci/vault/snapshot.go | 103 +++++ providers/oci/vault/snapshot_test.go | 256 ++++++++++++ providers/oci/vault/vault.go | 10 +- server/oci/vault/extras.go | 5 +- server/oci/vault/handler_test.go | 564 ++++++++++++++++++++++++++- server/oci/vault/secret.go | 7 +- 16 files changed, 1413 insertions(+), 40 deletions(-) create mode 100644 providers/oci/vault/snapshot.go create mode 100644 providers/oci/vault/snapshot_test.go 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/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/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/vault/key.go b/providers/oci/vault/key.go index e02259d27..9e83f3e66 100644 --- a/providers/oci/vault/key.go +++ b/providers/oci/vault/key.go @@ -139,7 +139,7 @@ func (m *Mock) newKeyLocked(spec *KeySpec, mode string) *keyData { DisplayName: spec.DisplayName, Shape: spec.Shape, ProtectionMode: mode, - LifecycleState: StateActive, + LifecycleState: StateEnabled, TimeCreated: m.now(), Scope: scope.Scope{Compartment: m.compartmentOr(spec.CompartmentID)}, FreeformTags: copyTags(spec.FreeformTags), @@ -266,7 +266,7 @@ func (m *Mock) CancelKeyDeletion(id string) (*KeyInfo, error) { return nil, cerrors.Newf(cerrors.FailedPrecondition, "key %s is not scheduled for deletion", id) } - k.LifecycleState = StateActive + k.LifecycleState = StateEnabled k.TimeOfDeletion = "" info := toKeyInfo(k) @@ -318,7 +318,7 @@ func (m *Mock) CreateKeyVersion(keyID string) (*KeyVersionInfo, error) { return nil, err } - if k.LifecycleState != StateActive { + if k.LifecycleState != StateEnabled { return nil, cerrors.Newf(cerrors.FailedPrecondition, "key %s is %s", keyID, k.LifecycleState) } @@ -442,7 +442,7 @@ func toKeyVersionInfo(kv *keyVersionData) KeyVersionInfo { KeyID: kv.KeyID, VaultID: kv.VaultID, CompartmentID: kv.Scope.Compartment, - LifecycleState: StateActive, + LifecycleState: StateEnabled, TimeCreated: kv.TimeCreated, } } diff --git a/providers/oci/vault/key_test.go b/providers/oci/vault/key_test.go index c6e3e1ca4..857311bb5 100644 --- a/providers/oci/vault/key_test.go +++ b/providers/oci/vault/key_test.go @@ -85,7 +85,7 @@ func TestCreateKey(t *testing.T) { require.NoError(t, err) assert.Equal(t, tc.expectMode, info.ProtectionMode) - assert.Equal(t, StateActive, info.LifecycleState) + 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) @@ -155,7 +155,7 @@ func TestKeyScheduledDeletionAndCancellation(t *testing.T) { restored, err := m.CancelKeyDeletion(keyID) require.NoError(t, err) - assert.Equal(t, StateActive, restored.LifecycleState) + assert.Equal(t, StateEnabled, restored.LifecycleState) _, err = m.CancelKeyDeletion(keyID) assert.Equal(t, cerrors.FailedPrecondition, cerrors.GetCode(err)) @@ -217,3 +217,41 @@ func TestUpdateAndMoveKey(t *testing.T) { _, 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 index efb63c8e8..76efbd219 100644 --- a/providers/oci/vault/portable.go +++ b/providers/oci/vault/portable.go @@ -13,6 +13,14 @@ import ( // 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 @@ -22,6 +30,8 @@ import ( // 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) { @@ -32,6 +42,13 @@ func (m *Mock) CreateSecret( 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{ @@ -58,9 +75,9 @@ func (m *Mock) DeleteSecret(_ context.Context, name string) error { m.mu.Lock() defer m.mu.Unlock() - s, ok := m.secretByNameLocked(name) - if !ok { - return cerrors.Newf(cerrors.NotFound, "secret %q not found", name) + s, err := m.portableSecretLocked(name) + if err != nil { + return err } return scheduleSecret(s, m.earliestDeletion(minSecretDeletionDays)) @@ -71,9 +88,9 @@ func (m *Mock) GetSecret(_ context.Context, name string) (*driver.SecretInfo, er m.mu.RLock() defer m.mu.RUnlock() - s, ok := m.secretByNameLocked(name) - if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "secret %q not found", name) + s, err := m.portableSecretLocked(name) + if err != nil { + return nil, err } info := toPortableInfo(s) @@ -81,7 +98,8 @@ func (m *Mock) GetSecret(_ context.Context, name string) (*driver.SecretInfo, er return &info, nil } -// ListSecrets returns every secret not pending deletion, ordered by OCID. +// 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() @@ -104,9 +122,9 @@ func (m *Mock) PutSecretValue(_ context.Context, name string, value []byte) (*dr m.mu.Lock() defer m.mu.Unlock() - s, ok := m.secretByNameLocked(name) - if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "secret %q not found", name) + s, err := m.portableSecretLocked(name) + if err != nil { + return nil, err } v := m.addVersionLocked(s, value, "", StageCurrent) @@ -120,9 +138,9 @@ func (m *Mock) GetSecretValue(_ context.Context, name, versionID string) (*drive m.mu.RLock() defer m.mu.RUnlock() - s, ok := m.secretByNameLocked(name) - if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "secret %q not found", name) + s, err := m.portableSecretLocked(name) + if err != nil { + return nil, err } sel, err := portableSelector(versionID) @@ -143,9 +161,9 @@ func (m *Mock) ListSecretVersions(_ context.Context, name string) ([]driver.Secr m.mu.RLock() defer m.mu.RUnlock() - s, ok := m.secretByNameLocked(name) - if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "secret %q not found", name) + s, err := m.portableSecretLocked(name) + if err != nil { + return nil, err } out := make([]driver.SecretVersion, 0, len(s.Versions)) @@ -156,6 +174,34 @@ func (m *Mock) ListSecretVersions(_ context.Context, name string) ([]driver.Secr 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) { diff --git a/providers/oci/vault/portable_test.go b/providers/oci/vault/portable_test.go index dc40f9032..a7e02ea29 100644 --- a/providers/oci/vault/portable_test.go +++ b/providers/oci/vault/portable_test.go @@ -181,3 +181,117 @@ func TestPortablePutSecretValueStagesLikeOCI(t *testing.T) { 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 index 0ab9bd3bf..6a08975c2 100644 --- a/providers/oci/vault/race_test.go +++ b/providers/oci/vault/race_test.go @@ -26,7 +26,14 @@ func TestConcurrentVersionWritesAndReads(t *testing.T) { m := newTestMock() ctx := context.Background() - s := newSecret(t, m, testCompartment, "hot", "initial") + + // 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 diff --git a/providers/oci/vault/secret.go b/providers/oci/vault/secret.go index 45374f003..122654ab6 100644 --- a/providers/oci/vault/secret.go +++ b/providers/oci/vault/secret.go @@ -114,8 +114,8 @@ func (m *Mock) validateSecretSpecLocked(spec *SecretSpec) error { return cerrors.Newf(cerrors.InvalidArgument, "key %s does not belong to vault %s", spec.KeyID, spec.VaultID) } - if _, ok := m.secretByNameLocked(spec.Name); ok { - return cerrors.Newf(cerrors.AlreadyExists, "secret %q already exists", spec.Name) + 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 @@ -322,7 +322,8 @@ func scheduleSecret(s *secretData, when string) error { } // CancelOCISecretDeletion returns a secret scheduled for deletion to ACTIVE. -// It fails if another secret has taken its name in the meantime. +// 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() @@ -336,7 +337,7 @@ func (m *Mock) CancelOCISecretDeletion(id string) (*SecretInfo, error) { return nil, cerrors.Newf(cerrors.FailedPrecondition, "secret %s is not scheduled for deletion", id) } - if other, ok := m.secretByNameLocked(s.Name); ok && other.ID != s.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) } @@ -392,10 +393,24 @@ func (m *Mock) secretLocked(id string) (*secretData, error) { return s, nil } -// secretByNameLocked finds a live secret by name. 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(name string) (*secretData, bool) { +// 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 @@ -405,7 +420,8 @@ func (m *Mock) secretByNameLocked(name string) (*secretData, bool) { return nil, false } -// secretByVaultAndNameLocked resolves OCI's getByName addressing. +// 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") @@ -415,6 +431,10 @@ func (m *Mock) secretByVaultAndNameLocked(vaultID, name string) (*secretData, er 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 diff --git a/providers/oci/vault/secret_test.go b/providers/oci/vault/secret_test.go index 337212690..56f043939 100644 --- a/providers/oci/vault/secret_test.go +++ b/providers/oci/vault/secret_test.go @@ -259,3 +259,88 @@ func TestChangeSecretCompartment(t *testing.T) { 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 index 30a67373c..88797a653 100644 --- a/providers/oci/vault/vault.go +++ b/providers/oci/vault/vault.go @@ -25,9 +25,17 @@ 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. +// 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" ) diff --git a/server/oci/vault/extras.go b/server/oci/vault/extras.go index 01e01d763..e31c6685c 100644 --- a/server/oci/vault/extras.go +++ b/server/oci/vault/extras.go @@ -4,11 +4,10 @@ import ( vaultprovider "github.com/stackshy/cloudemu/v2/providers/oci/vault" ) -// VaultManagement is the KMS vault surface. -// +// 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. The portable secrets driver has no +//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) diff --git a/server/oci/vault/handler_test.go b/server/oci/vault/handler_test.go index d9f7901e2..69a1a4cd3 100644 --- a/server/oci/vault/handler_test.go +++ b/server/oci/vault/handler_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" "testing" "time" @@ -14,6 +15,7 @@ import ( "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" @@ -357,7 +359,7 @@ func TestKeyLifecycleAndRotationOverTheWire(t *testing.T) { canceled := f.do(http.MethodPost, "/20180608/keys/"+keyID+"/actions/cancelDeletion", nil) require.Equal(t, http.StatusOK, canceled.Code) - assert.Equal(t, "ACTIVE", decode(t, canceled)["lifecycleState"]) + assert.Equal(t, "ENABLED", decode(t, canceled)["lifecycleState"]) } func TestKeyErrors(t *testing.T) { @@ -773,3 +775,563 @@ func (portableOnly) GetSecretValue(context.Context, string, string) (*secretsdri 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/secret.go b/server/oci/vault/secret.go index bb3845578..fe10aeb75 100644 --- a/server/oci/vault/secret.go +++ b/server/oci/vault/secret.go @@ -171,7 +171,12 @@ func (h *Handler) createSecret(w http.ResponseWriter, r *http.Request) { } // writeSecretAction records the work request for a secret mutation OCI answers -// with headers only. +// 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, ) {