diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..d57f2b7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# Lines starting with '#' are comments. +# Each line is a file pattern followed by one or more owners. + +# These owners will be the default owners for everything in the repo. +* @galaxy-io/galaxy diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..8b18516 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,49 @@ +name: "CI" + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + go: + # Release Please PRs only bump versions; skipped checks still satisfy the ruleset. + if: github.event.pull_request.user.login != 'gx-null[bot]' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: .go-version + cache-dependency-path: "**/go.sum" + + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.15.0 + install-only: true + + - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0 + + # Modules build against each other's working copies, not their tags. + - name: Workspace + run: just work + + - name: Tidy + run: just tidy check + + - name: Format + run: just format check + + - name: Lint + run: just lint check + + - name: Test + run: just test diff --git a/.github/workflows/pr-title.yaml b/.github/workflows/pr-title.yaml new file mode 100644 index 0000000..dcd9ff0 --- /dev/null +++ b/.github/workflows/pr-title.yaml @@ -0,0 +1,18 @@ +name: "PR Title" + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read + +jobs: + conventional: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # Squash merges land the title as the commit, which release-please reads. + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml new file mode 100644 index 0000000..411c827 --- /dev/null +++ b/.github/workflows/release-please.yaml @@ -0,0 +1,29 @@ +name: "Release Please" + +on: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: release-please + cancel-in-progress: false + +jobs: + release-please: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # Tags pushed with the default token do not trigger other workflows; the + # app's do. + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: app-token + with: + app-id: ${{ secrets.GX_NULL_APP_ID }} + private-key: ${{ secrets.GX_NULL_PRIVATE_KEY }} + + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ steps.app-token.outputs.token }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42e9f7c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +go.work +go.work.sum diff --git a/.go-version b/.go-version new file mode 100644 index 0000000..ea0928c --- /dev/null +++ b/.go-version @@ -0,0 +1 @@ +1.26.4 diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..4e7aad8 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,90 @@ +version: "2" + +run: + timeout: 10m + modules-download-mode: readonly + +formatters: + enable: + - gofumpt + - goimports + + settings: + gofumpt: + extra: + group-params: true + + goimports: + local-prefixes: + - github.com/galaxy-io/modules + +linters: + enable: + # Bugs + - staticcheck + - gosec + - errcheck + - govet + + # Performance + - prealloc + - ineffassign + + # Style + - gocritic + - revive + - misspell + + # Complexity + - gocyclo + - funlen + + # Unused code + - unused + - unparam + + settings: + gocritic: + enabled-tags: + - diagnostic + - style + - performance + disabled-checks: + - hugeParam + - rangeValCopy + - unnamedResult + + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + - name: increment-decrement + - name: var-naming + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + - name: indent-error-flow + - name: errorf + + gocyclo: + min-complexity: 30 + + funlen: + lines: 100 + statements: 60 + + gosec: + excludes: + - G104 + - G304 + + exclusions: + paths: + - ".*_test\\.go" + - ".*\\.pb\\.go" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..2af8910 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + "secret": "0.0.0" +} diff --git a/README.md b/README.md index ff5c58e..76fb380 100644 --- a/README.md +++ b/README.md @@ -1 +1,27 @@ -# modules \ No newline at end of file +# galaxy-io/modules + +[![CI](https://github.com/galaxy-io/modules/actions/workflows/ci.yaml/badge.svg)](https://github.com/galaxy-io/modules/actions/workflows/ci.yaml) + +Shared Go building blocks for Galaxy apps. Each module is versioned on its own. + +| Module | Description | | +| --- | --- | --- | +| [`secret`](secret) | Resolves secret refs to values, scoped per tenant. | [![Go Reference](https://pkg.go.dev/badge/github.com/galaxy-io/modules/secret.svg)](https://pkg.go.dev/github.com/galaxy-io/modules/secret) [![Version](https://img.shields.io/github/v/tag/galaxy-io/modules?filter=secret/*&label=version)](https://github.com/galaxy-io/modules/tags) | + +## Install + +```sh +go get github.com/galaxy-io/modules/{name}@latest +``` + +## Development + +Install Go, [`just`](https://github.com/casey/just), and [`golangci-lint`](https://golangci-lint.run), then: + +```sh +just work +just tidy +just format +just lint +just test +``` diff --git a/justfile b/justfile new file mode 100644 index 0000000..e2f0d36 --- /dev/null +++ b/justfile @@ -0,0 +1,51 @@ +default: + @just --list + +# run cmd in every Go module +_each cmd: + for dir in $(find . -name go.mod -exec dirname {} \;); do (cd "$dir" && {{ cmd }}) || exit 1; done + +# write the gitignored go.work: every module, with each version one module requires of another replaced by its local copy, so they build together before they're tagged +work: + #!/usr/bin/env bash + set -euo pipefail + rm -f go.work go.work.sum + go work init + for dir in $(find . -name go.mod -exec dirname {} \; | sort); do + go work use "$dir" + path=$(awk '/^module /{print $2}' "$dir/go.mod") + for version in $(grep -ho "$path v[^ ]*" $(find . -name go.mod) | awk '{print $2}' | sort -u); do + go work edit -replace "$path@$version=$dir" + done + done + +# go mod tidy in every Go module; `just tidy check` verifies without writing. tidy ignores +# go.work, so sibling modules are replaced by their local copies for the run and dropped after. +tidy mode="fix": + #!/usr/bin/env bash + set -euo pipefail + root=$PWD + for dir in $(find . -name go.mod -exec dirname {} \; | sort); do + ( + cd "$dir" + replaced=() + trap 'for r in ${replaced[@]+"${replaced[@]}"}; do go mod edit -dropreplace "$r"; done' EXIT + for sibling in $(find "$root" -name go.mod -exec dirname {} \;); do + path=$(awk '/^module /{print $2}' "$sibling/go.mod") + for version in $(grep -o "$path v[^ ]*" go.mod | awk '{print $2}' | sort -u); do + go mod edit -replace "$path@$version=$sibling" + replaced+=("$path@$version") + done + done + go mod tidy {{ if mode == "check" { "-diff" } else { "" } }} + ) + done + +# run tests in every Go module +test: (_each "go test ./...") + +# gofumpt + goimports across every Go module (settings in .golangci.yaml); `just format check` verifies without writing +format mode="fix": (_each ("golangci-lint fmt " + (if mode == "check" { "--diff " } else { "" }) + "./...")) + +# Go linters across every Go module; `just lint check` runs without fixing +lint mode="fix": (_each ("golangci-lint run " + (if mode == "check" { "" } else { "--fix " }) + "./...")) diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..1214952 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "skip-changelog": true, + "bump-minor-pre-major": true, + "release-type": "go", + "tag-separator": "/", + "packages": { + "secret": { + "component": "secret" + } + } +} diff --git a/secret/aws/aws.go b/secret/aws/aws.go new file mode 100644 index 0000000..42b22f0 --- /dev/null +++ b/secret/aws/aws.go @@ -0,0 +1,215 @@ +// Package aws implements secret.Secrets backed by AWS Secrets Manager. +// +// A secret reference maps directly to a Secrets Manager secret name. Both the +// plaintext value and its metadata are preserved by storing a JSON envelope in +// the secret's SecretString field, so Read/Write/Delete round-trip the full +// secret.Secret the way the env and postgres providers do. +package aws + +import ( + "cmp" + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + smtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" + + "github.com/galaxy-io/modules/secret" +) + +var _ secret.Secrets = (*Provider)(nil) + +// API is the subset of the Secrets Manager client the provider uses. It is +// satisfied by *secretsmanager.Client and is exported so callers can inject a +// fake in tests. +type API interface { + GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) + CreateSecret(ctx context.Context, params *secretsmanager.CreateSecretInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.CreateSecretOutput, error) + PutSecretValue(ctx context.Context, params *secretsmanager.PutSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.PutSecretValueOutput, error) + DeleteSecret(ctx context.Context, params *secretsmanager.DeleteSecretInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.DeleteSecretOutput, error) +} + +// Provider stores secrets in AWS Secrets Manager. +type Provider struct { + client API + scope secret.Scope + prefix string +} + +// Option configures a Provider. +type Option func(*Provider) + +// WithPrefix replaces the scope's root segment at the root of every +// Secrets Manager name. A slash always separates the prefix from the rest of +// the name, so a trailing one is trimmed. +func WithPrefix(prefix string) Option { return func(p *Provider) { p.prefix = prefix } } + +// New constructs a provider from an explicit Secrets Manager API. Use this to +// inject a preconfigured client (custom region, credentials, or a test fake). +// scope names the refs the app mints for tenants. +func New(client API, scope secret.Scope, opts ...Option) (*Provider, error) { + if err := scope.Validate(); err != nil { + return nil, err + } + p := &Provider{client: client, scope: scope} + for _, opt := range opts { + opt(p) + } + p.prefix = strings.TrimRight(p.prefix, "/") + return p, nil +} + +// NewFromConfig loads the default AWS config chain (env, shared config, +// IAM role) and returns a Secrets Manager–backed provider. Fails fast when +// no region is configured. +func NewFromConfig(ctx context.Context, scope secret.Scope, opts ...Option) (*Provider, error) { + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("secret/aws: load config: %w", err) + } + if cfg.Region == "" { + return nil, errors.New("secret/aws: no region configured (set AWS_REGION)") + } + return New(secretsmanager.NewFromConfig(cfg), scope, opts...) +} + +// Name returns the provider identifier. +func (p *Provider) Name() string { return "aws-secrets-manager" } + +// envelope is the JSON stored as the secret's SecretString so both value and +// metadata survive a round trip. +type envelope struct { + Value []byte `json:"value"` + Meta map[string]string `json:"meta,omitempty"` +} + +// name maps a ref to its Secrets Manager name. Scoped refs swap their root +// segment for the prefix; any other ref is nested +// under the prefix when one is set and used verbatim otherwise. +func (p *Provider) name(ref string) string { + rest, scoped := strings.CutPrefix(ref, p.scope.Prefix) + switch { + case scoped: + return cmp.Or(p.prefix, p.scope.Root()) + "/" + rest + case p.prefix != "": + return p.prefix + "/" + ref + } + return ref +} + +// Read fetches and decodes the current secret at ref. +func (p *Provider) Read(ctx context.Context, ref string) (secret.Secret, error) { + name := p.name(ref) + input := &secretsmanager.GetSecretValueInput{SecretId: aws.String(name)} + out, err := p.client.GetSecretValue(ctx, input) + if err != nil { + // Scheduled-for-deletion secrets surface as InvalidRequestException. + var notFound *smtypes.ResourceNotFoundException + var invalid *smtypes.InvalidRequestException + if errors.As(err, ¬Found) || errors.As(err, &invalid) { + return secret.Secret{}, fmt.Errorf("secret/aws: read %q: %w", ref, secret.ErrNotFound) + } + return secret.Secret{}, fmt.Errorf("secret/aws: read %q: %w", ref, err) + } + var raw []byte + switch { + case out.SecretString != nil: + raw = []byte(*out.SecretString) + case out.SecretBinary != nil: + raw = out.SecretBinary + default: + return secret.Secret{}, fmt.Errorf("secret/aws: read %q: empty secret", ref) + } + var env envelope + if err := json.Unmarshal(raw, &env); err != nil { + return secret.Secret{}, fmt.Errorf("secret/aws: read %q: decode: %w", ref, err) + } + // Unknown keys are ignored, so foreign secrets decode to a nil Value. + if env.Value == nil { + return secret.Secret{}, fmt.Errorf("secret/aws: read %q: not a managed envelope", ref) + } + return secret.Secret{Value: env.Value, Meta: env.Meta}, nil +} + +// Write creates the secret if absent, otherwise stores a new version. +func (p *Provider) Write(ctx context.Context, ref string, s secret.Secret) error { + return p.write(ctx, ref, s, true) +} + +// RotateSecret appends a version under the existing name, never creating a secret. +func (p *Provider) RotateSecret(ctx context.Context, ref string, s secret.Secret) error { + if s.Tenant == "" { + return errors.New("secret/aws: tenant is required") + } + if err := p.scope.Check(ref, s.Tenant); err != nil { + return err + } + return p.write(ctx, ref, s, false) +} + +func (p *Provider) write(ctx context.Context, ref string, s secret.Secret, create bool) error { + name := p.name(ref) + raw, err := json.Marshal(envelope{Value: s.Value, Meta: s.Meta}) + if err != nil { + return fmt.Errorf("secret/aws: encode: %w", err) + } + token := fmt.Sprintf("%x", rand.Text()) + put := func() error { + _, err := p.client.PutSecretValue(ctx, &secretsmanager.PutSecretValueInput{SecretId: aws.String(name), SecretString: aws.String(string(raw)), ClientRequestToken: aws.String(token)}) + if err != nil { + return err + } + return nil + } + err = put() + if err == nil { + return nil + } + var notFound *smtypes.ResourceNotFoundException + if errors.As(err, ¬Found) && !create { + return fmt.Errorf("secret/aws: rotate %q: %w", ref, secret.ErrNotFound) + } + if !errors.As(err, ¬Found) { + return fmt.Errorf("secret/aws: write %q: %w", ref, err) + } + _, err = p.client.CreateSecret(ctx, &secretsmanager.CreateSecretInput{Name: aws.String(name), SecretString: aws.String(string(raw)), ClientRequestToken: aws.String(token)}) + if err != nil { + var exists *smtypes.ResourceExistsException + if errors.As(err, &exists) { + return put() + } + return fmt.Errorf("secret/aws: create %q: %w", ref, err) + } + return nil +} + +// ErrUnmanaged is returned by Delete for a secret the provider cannot prove +// it manages. +var ErrUnmanaged = errors.New("secret/aws: refusing to delete an unmanaged secret") + +// Delete schedules the secret at ref for deletion using Secrets Manager's +// default recovery window; deleting a missing ref is a no-op. Refs outside +// the managed namespace (the configured prefix, or without one, the scope) +// return ErrUnmanaged. +func (p *Provider) Delete(ctx context.Context, ref string) error { + if p.prefix == "" && !strings.HasPrefix(ref, p.scope.Prefix) { + return fmt.Errorf("delete %q: %w", ref, ErrUnmanaged) + } + _, err := p.client.DeleteSecret(ctx, &secretsmanager.DeleteSecretInput{ + SecretId: aws.String(p.name(ref)), + }) + if err != nil { + var notFound *smtypes.ResourceNotFoundException + if errors.As(err, ¬Found) { + return nil + } + return fmt.Errorf("secret/aws: delete %q: %w", ref, err) + } + return nil +} diff --git a/secret/aws/aws_test.go b/secret/aws/aws_test.go new file mode 100644 index 0000000..53d08b4 --- /dev/null +++ b/secret/aws/aws_test.go @@ -0,0 +1,97 @@ +package aws + +import ( + "context" + "errors" + "testing" + + sdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + smtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types" + + "github.com/galaxy-io/modules/secret" +) + +type fakeAPI struct { + API + put *secretsmanager.PutSecretValueInput + get *secretsmanager.GetSecretValueInput + create *secretsmanager.CreateSecretInput + missing bool + raw string +} + +func (f *fakeAPI) PutSecretValue(_ context.Context, in *secretsmanager.PutSecretValueInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.PutSecretValueOutput, error) { + f.put = in + if f.missing { + return nil, &smtypes.ResourceNotFoundException{} + } + f.raw = sdk.ToString(in.SecretString) + return &secretsmanager.PutSecretValueOutput{VersionId: in.ClientRequestToken}, nil +} + +func (f *fakeAPI) CreateSecret(_ context.Context, in *secretsmanager.CreateSecretInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.CreateSecretOutput, error) { + f.create = in + f.raw = sdk.ToString(in.SecretString) + return &secretsmanager.CreateSecretOutput{VersionId: in.ClientRequestToken}, nil +} + +func (f *fakeAPI) GetSecretValue(_ context.Context, in *secretsmanager.GetSecretValueInput, _ ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { + f.get = in + return &secretsmanager.GetSecretValueOutput{SecretString: sdk.String(f.raw), VersionId: in.VersionId}, nil +} + +var scope = secret.Scope{Prefix: "filament/"} + +func TestRotationKeepsSecretName(t *testing.T) { + for _, missing := range []bool{false, true} { + t.Run(map[bool]string{false: "existing", true: "missing"}[missing], func(t *testing.T) { + api := &fakeAPI{missing: missing} + p, err := New(api, scope, WithPrefix("company")) + if err != nil { + t.Fatal(err) + } + ref := scope.Ref("tenant", "connection", "token", "v1") + err = p.RotateSecret(t.Context(), ref, secret.Secret{Tenant: "tenant", Value: []byte("rotated")}) + if missing { + if !errors.Is(err, secret.ErrNotFound) { + t.Fatalf("missing secret: %v", err) + } + } else { + if err != nil { + t.Fatal(err) + } + value, err := p.Read(t.Context(), ref) + if err != nil || string(value.Value) != "rotated" { + t.Fatal("rotation not readable") + } + if api.get.VersionId != nil { + t.Fatal("read pinned an old version") + } + } + if api.create != nil { + t.Fatal("rotation created a secret") + } + if sdk.ToString(api.put.SecretId) != "company/tenant/connection/token/v1" { + t.Fatal("secret name changed") + } + if len(sdk.ToString(api.put.ClientRequestToken)) < 32 { + t.Fatal("missing idempotency token") + } + }) + } +} + +func TestWriteCanCreateSecret(t *testing.T) { + api := &fakeAPI{missing: true} + p, err := New(api, scope) + if err != nil { + t.Fatal(err) + } + if err := p.Write(t.Context(), "ref", secret.Secret{Value: []byte("initial")}); err != nil { + t.Fatal(err) + } + if api.create == nil || sdk.ToString(api.create.ClientRequestToken) != sdk.ToString(api.put.ClientRequestToken) { + t.Fatal("create lost idempotency token") + } +} diff --git a/secret/datastore/datastore.go b/secret/datastore/datastore.go new file mode 100644 index 0000000..ed2ea99 --- /dev/null +++ b/secret/datastore/datastore.go @@ -0,0 +1,186 @@ +// Package datastore implements secret.Secrets over the app's own datastore. It +// encrypts values with AES-GCM and enforces the scope's tenant rule; the app +// owns the schema and supplies the rows through Store. +package datastore + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + + "github.com/galaxy-io/modules/secret" +) + +// Row is one encrypted secret as the app stores it. +type Row struct { + Tenant string + Ref string + Ciphertext []byte + Nonce []byte + KeyID string + // Meta is the JSON-encoded secret metadata. + Meta []byte +} + +// Store persists rows in the app's secrets table, unique on (tenant, ref) and +// on ref. tenant on Get and Delete is the owner of a scoped ref and empty for +// a ref outside the scope; a store matches on it when it is set. +type Store interface { + // Put inserts the row or replaces the one at (tenant, ref). + Put(ctx context.Context, r Row) error + // Replace updates the row at (tenant, ref), reporting false when none + // exists. It never inserts. + Replace(ctx context.Context, r Row) (bool, error) + // Get returns the row at ref, wrapping secret.ErrNotFound when missing. + Get(ctx context.Context, tenant, ref string) (Row, error) + // Delete removes the row at ref; a missing row is not an error. + Delete(ctx context.Context, tenant, ref string) error +} + +// Provider encrypts secret values with AES-GCM before handing them to Store. +type Provider struct { + store Store + scope secret.Scope + keyID string + gcm cipher.AEAD + name string +} + +var ( + _ secret.Secrets = (*Provider)(nil) + _ secret.Rotator = (*Provider)(nil) +) + +// Option configures a Provider. +type Option func(*Provider) + +// WithName replaces the default "datastore" provider name, such as with the +// backing datastore's. +func WithName(name string) Option { return func(p *Provider) { p.name = name } } + +// NewFromEnv constructs a provider from ENCRYPTION_KEY (base64-encoded AES +// key) and ENCRYPTION_KEY_ID (defaults to "default"). +func NewFromEnv(store Store, scope secret.Scope, opts ...Option) (*Provider, error) { + encoded := os.Getenv("ENCRYPTION_KEY") + if encoded == "" { + return nil, errors.New("secret/datastore: ENCRYPTION_KEY is required") + } + key, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("secret/datastore: ENCRYPTION_KEY must be base64: %w", err) + } + keyID := os.Getenv("ENCRYPTION_KEY_ID") + if keyID == "" { + keyID = "default" + } + return New(store, scope, keyID, key, opts...) +} + +// New constructs a provider. key must be 16, 24, or 32 bytes. +func New(store Store, scope secret.Scope, keyID string, key []byte, opts ...Option) (*Provider, error) { + if err := scope.Validate(); err != nil { + return nil, err + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("secret/datastore: key: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("secret/datastore: gcm: %w", err) + } + p := &Provider{store: store, scope: scope, keyID: keyID, gcm: gcm, name: "datastore"} + for _, opt := range opts { + opt(p) + } + return p, nil +} + +// Name returns the provider identifier. +func (p *Provider) Name() string { return p.name } + +// Write stores s at ref, creating or replacing it. +func (p *Provider) Write(ctx context.Context, ref string, s secret.Secret) error { + return p.write(ctx, ref, s, false) +} + +// RotateSecret updates an existing tenant-owned secret without inserting a row. +func (p *Provider) RotateSecret(ctx context.Context, ref string, s secret.Secret) error { + return p.write(ctx, ref, s, true) +} + +func (p *Provider) write(ctx context.Context, ref string, s secret.Secret, rotate bool) error { + if s.Tenant == "" { + return errors.New("secret/datastore: tenant is required") + } + if err := p.scope.Check(ref, s.Tenant); err != nil { + return err + } + nonce := make([]byte, p.gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return fmt.Errorf("secret/datastore: nonce: %w", err) + } + meta, err := json.Marshal(s.Meta) + if err != nil { + return fmt.Errorf("secret/datastore: marshal metadata: %w", err) + } + row := Row{Tenant: s.Tenant, Ref: ref, Ciphertext: p.gcm.Seal(nil, nonce, s.Value, nil), Nonce: nonce, KeyID: p.keyID, Meta: meta} + if rotate { + var found bool + found, err = p.store.Replace(ctx, row) + if err == nil && !found { + return secret.ErrNotFound + } + } else { + err = p.store.Put(ctx, row) + } + if err != nil { + return fmt.Errorf("secret/datastore: write: %w", err) + } + return nil +} + +// Read returns the decrypted secret at ref. +func (p *Provider) Read(ctx context.Context, ref string) (secret.Secret, error) { + owner, _, err := p.scope.Owner(ref) + if err != nil { + return secret.Secret{}, err + } + row, err := p.store.Get(ctx, owner, ref) + if err != nil { + if errors.Is(err, secret.ErrNotFound) { + return secret.Secret{}, fmt.Errorf("read secret %q: %w", ref, secret.ErrNotFound) + } + return secret.Secret{}, fmt.Errorf("secret/datastore: read: %w", err) + } + value, err := p.gcm.Open(nil, row.Nonce, row.Ciphertext, nil) + if err != nil { + return secret.Secret{}, fmt.Errorf("secret/datastore: decrypt %q: %w", ref, err) + } + var meta map[string]string + if len(row.Meta) > 0 { + if err := json.Unmarshal(row.Meta, &meta); err != nil { + return secret.Secret{}, fmt.Errorf("secret/datastore: unmarshal metadata: %w", err) + } + } + return secret.Secret{Tenant: row.Tenant, Value: value, Meta: meta}, nil +} + +// Delete removes the secret at ref; deleting a missing ref is a no-op. +func (p *Provider) Delete(ctx context.Context, ref string) error { + owner, _, err := p.scope.Owner(ref) + if err != nil { + return err + } + if err := p.store.Delete(ctx, owner, ref); err != nil { + return fmt.Errorf("secret/datastore: delete: %w", err) + } + return nil +} diff --git a/secret/datastore/datastore_test.go b/secret/datastore/datastore_test.go new file mode 100644 index 0000000..c9121b4 --- /dev/null +++ b/secret/datastore/datastore_test.go @@ -0,0 +1,143 @@ +package datastore_test + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/galaxy-io/modules/secret" + "github.com/galaxy-io/modules/secret/datastore" +) + +// memStore mirrors the table contract: rows keyed by ref, unique per tenant. +type memStore struct{ rows map[string]datastore.Row } + +func (m *memStore) Put(_ context.Context, r datastore.Row) error { + if old, ok := m.rows[r.Ref]; ok && old.Tenant != r.Tenant { + return errors.New("unique ref violated") + } + m.rows[r.Ref] = r + return nil +} + +func (m *memStore) Replace(_ context.Context, r datastore.Row) (bool, error) { + if old, ok := m.rows[r.Ref]; !ok || old.Tenant != r.Tenant { + return false, nil + } + m.rows[r.Ref] = r + return true, nil +} + +func (m *memStore) Get(_ context.Context, tenant, ref string) (datastore.Row, error) { + r, ok := m.rows[ref] + if !ok || tenant != "" && r.Tenant != tenant { + return datastore.Row{}, secret.ErrNotFound + } + return r, nil +} + +func (m *memStore) Delete(_ context.Context, tenant, ref string) error { + if r, ok := m.rows[ref]; ok && (tenant == "" || r.Tenant == tenant) { + delete(m.rows, ref) + } + return nil +} + +var scope = secret.Scope{Prefix: "filament/"} + +func newProvider(t *testing.T) (*datastore.Provider, *memStore) { + t.Helper() + store := &memStore{rows: map[string]datastore.Row{}} + p, err := datastore.New(store, scope, "test-key-v1", make([]byte, 32)) + if err != nil { + t.Fatal(err) + } + return p, store +} + +func TestRoundTripAndRotation(t *testing.T) { + p, store := newProvider(t) + ref := scope.Ref("tenant-a", "conn", "dsn", "v1") + original := secret.Secret{Tenant: "tenant-a", Value: []byte("postgres://user:pw@host/db"), Meta: map[string]string{"rotated": "2026-01-01"}} + if err := p.Write(t.Context(), ref, original); err != nil { + t.Fatal(err) + } + if bytes.Contains(store.rows[ref].Ciphertext, original.Value) || store.rows[ref].KeyID != "test-key-v1" { + t.Fatal("stored plaintext or lost key id") + } + got, err := p.Read(t.Context(), ref) + if err != nil || string(got.Value) != string(original.Value) || got.Meta["rotated"] != "2026-01-01" || got.Tenant != "tenant-a" { + t.Fatalf("round trip = %+v %v", got, err) + } + original.Value = []byte("rotated") + if err := p.RotateSecret(t.Context(), ref, original); err != nil { + t.Fatal(err) + } + if got, err := p.Read(t.Context(), ref); err != nil || string(got.Value) != "rotated" { + t.Fatal("rotation not persisted") + } + wrong := original + wrong.Tenant = "tenant-b" + if err := p.RotateSecret(t.Context(), ref, wrong); err == nil { + t.Fatal("rotation crossed tenant boundary") + } + if err := p.Write(t.Context(), ref, wrong); err == nil { + t.Fatal("write crossed tenant boundary") + } + if err := p.Delete(t.Context(), ref); err != nil { + t.Fatal(err) + } + if _, err := p.Read(t.Context(), ref); !errors.Is(err, secret.ErrNotFound) { + t.Fatalf("read after delete: %v", err) + } + if err := p.RotateSecret(t.Context(), ref, original); !errors.Is(err, secret.ErrNotFound) { + t.Fatal("rotation recreated a deleted secret") + } +} + +func TestUnscopedRefs(t *testing.T) { + p, _ := newProvider(t) + ref := "shared-reference" + value := secret.Secret{Tenant: "default", Value: []byte("initial")} + if err := p.Write(t.Context(), ref, value); err != nil { + t.Fatal(err) + } + if got, err := p.Read(t.Context(), ref); err != nil || string(got.Value) != "initial" { + t.Fatalf("read unscoped: %v", err) + } + other := value + other.Tenant = "other" + if err := p.RotateSecret(t.Context(), ref, other); !errors.Is(err, secret.ErrNotFound) { + t.Fatal("rotation crossed tenant boundary") + } +} + +func TestStoreReceivesOwner(t *testing.T) { + p, store := newProvider(t) + ref := scope.Ref("tenant-a", "conn", "token", "v1") + if err := p.Write(t.Context(), ref, secret.Secret{Tenant: "tenant-a", Value: []byte("x")}); err != nil { + t.Fatal(err) + } + // A row whose tenant disagrees with its ref's owner is invisible. + r := store.rows[ref] + r.Tenant = "tenant-b" + store.rows[ref] = r + if _, err := p.Read(t.Context(), ref); !errors.Is(err, secret.ErrNotFound) { + t.Fatal("read ignored the ref's owner") + } +} + +func TestNewValidates(t *testing.T) { + store := &memStore{rows: map[string]datastore.Row{}} + if _, err := datastore.New(store, secret.Scope{}, "k", make([]byte, 32)); err == nil { + t.Fatal("accepted empty scope") + } + if _, err := datastore.New(store, scope, "k", make([]byte, 7)); err == nil { + t.Fatal("accepted bad key length") + } + p, err := datastore.New(store, scope, "k", make([]byte, 32), datastore.WithName("postgres")) + if err != nil || p.Name() != "postgres" { + t.Fatal("name option ignored") + } +} diff --git a/secret/env/env.go b/secret/env/env.go new file mode 100644 index 0000000..a58e1c3 --- /dev/null +++ b/secret/env/env.go @@ -0,0 +1,53 @@ +// Package env is a secret.Secrets provider backed by process environment +// variables. A reference like "postgres-creds/dsn" maps to the env var +// POSTGRES_CREDS_DSN. It is read-only and intended for local and development +// deployments. +package env + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/galaxy-io/modules/secret" +) + +var _ secret.Secrets = (*Provider)(nil) + +// ErrReadOnly is returned by writes: the process environment is fixed at runtime. +var ErrReadOnly = errors.New("env secret provider is read-only") + +// Provider reads secrets from environment variables. +type Provider struct{} + +// New returns an env-backed secret provider. +func New() *Provider { return &Provider{} } + +// Read returns the value of the environment variable that ref maps to. +func (p *Provider) Read(ctx context.Context, ref string) (secret.Secret, error) { + key := envKey(ref) + v, ok := os.LookupEnv(key) + if !ok { + return secret.Secret{}, fmt.Errorf("env: %q (%s) not set: %w", ref, key, secret.ErrNotFound) + } + return secret.Secret{Value: []byte(v)}, nil +} + +// Write is unsupported: the process environment is read-only at runtime. +func (p *Provider) Write(ctx context.Context, ref string, s secret.Secret) error { + return ErrReadOnly +} + +// Delete is unsupported: the process environment is read-only at runtime. +func (p *Provider) Delete(ctx context.Context, ref string) error { return ErrReadOnly } + +// Name identifies the provider. +func (p *Provider) Name() string { return "env" } + +// envKey converts a secret reference to an environment variable name: +// upper-cased, with '-', '/', and '.' replaced by '_'. +func envKey(ref string) string { + return strings.ToUpper(strings.NewReplacer("-", "_", "/", "_", ".", "_").Replace(ref)) +} diff --git a/secret/gcp/gcp.go b/secret/gcp/gcp.go new file mode 100644 index 0000000..bc24f29 --- /dev/null +++ b/secret/gcp/gcp.go @@ -0,0 +1,326 @@ +// Package gcp implements secret.Secrets backed by Google Cloud Secret Manager. +package gcp + +import ( + "cmp" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + + secretmanager "cloud.google.com/go/secretmanager/apiv1" + "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" + "github.com/googleapis/gax-go/v2" + "google.golang.org/api/option" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/galaxy-io/modules/secret" +) + +const ( + managedLabel = "managed-by" + maxSecretID = 255 +) + +var ( + _ secret.Secrets = (*Provider)(nil) + prefixPattern = regexp.MustCompile(`^[A-Za-z0-9_-]*$`) + // rootPattern keeps the scope root valid as a GCP label value. + rootPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,62}$`) +) + +// API is the subset of the Secret Manager client used by Provider. +type API interface { + AccessSecretVersion(context.Context, *secretmanagerpb.AccessSecretVersionRequest, ...gax.CallOption) (*secretmanagerpb.AccessSecretVersionResponse, error) + GetSecret(context.Context, *secretmanagerpb.GetSecretRequest, ...gax.CallOption) (*secretmanagerpb.Secret, error) + CreateSecret(context.Context, *secretmanagerpb.CreateSecretRequest, ...gax.CallOption) (*secretmanagerpb.Secret, error) + AddSecretVersion(context.Context, *secretmanagerpb.AddSecretVersionRequest, ...gax.CallOption) (*secretmanagerpb.SecretVersion, error) + DeleteSecret(context.Context, *secretmanagerpb.DeleteSecretRequest, ...gax.CallOption) error + DestroySecretVersion(context.Context, *secretmanagerpb.DestroySecretVersionRequest, ...gax.CallOption) (*secretmanagerpb.SecretVersion, error) +} + +// Provider stores secrets in Google Cloud Secret Manager. +type Provider struct { + client API + scope secret.Scope + projectID string + region string + prefix string + close func() error +} + +// Option configures a Provider. +type Option func(*Provider) + +// WithPrefix replaces the scope's root as the prefix on each GCP secret ID. A +// hyphen always separates the prefix from the hash, so a trailing one is trimmed. +func WithPrefix(prefix string) Option { return func(p *Provider) { p.prefix = prefix } } + +// New constructs a provider from an explicit Secret Manager API. scope names +// the refs the app mints for tenants; its root labels the secrets the +// provider creates, so it can prove it owns them later. +func New(client API, scope secret.Scope, projectID, region string, opts ...Option) (*Provider, error) { + if err := scope.Validate(); err != nil { + return nil, err + } + if !rootPattern.MatchString(scope.Root()) { + return nil, fmt.Errorf("secret/gcp: scope root %q must be a valid label value", scope.Root()) + } + p := &Provider{client: client, scope: scope, projectID: projectID, region: region} + for _, opt := range opts { + opt(p) + } + p.prefix = strings.TrimRight(p.prefix, "-_") + if strings.TrimSpace(projectID) == "" { + return nil, errors.New("secret/gcp: project ID is required (set GCP_PROJECT_ID)") + } + if strings.TrimSpace(region) == "" { + return nil, errors.New("secret/gcp: region is required (set GCP_REGION)") + } + if strings.Contains(region, "/") { + return nil, errors.New("secret/gcp: GCP_REGION must be a location ID") + } + if !prefixPattern.MatchString(p.prefix) { + return nil, errors.New("secret/gcp: SECRETS_PREFIX may contain only letters, digits, hyphens, and underscores") + } + if len(p.prefix)+1+sha256.Size*2 > maxSecretID { + return nil, fmt.Errorf("secret/gcp: SECRETS_PREFIX is too long (maximum %d characters)", maxSecretID-1-sha256.Size*2) + } + return p, nil +} + +// NewFromConfig uses Application Default Credentials to construct a provider. +func NewFromConfig(ctx context.Context, scope secret.Scope, projectID, region string, opts ...Option) (*Provider, error) { + p, err := New(nil, scope, projectID, region, opts...) + if err != nil { + return nil, err + } + endpoint := fmt.Sprintf("secretmanager.%s.rep.googleapis.com:443", region) + client, err := secretmanager.NewClient(ctx, option.WithEndpoint(endpoint)) + if err != nil { + return nil, fmt.Errorf("secret/gcp: create client: %w", err) + } + p.client = client + p.close = client.Close + return p, nil +} + +// Name returns the provider identifier. +func (p *Provider) Name() string { return "gcp-secret-manager" } + +// Close releases the underlying Google Cloud client when Provider created it. +func (p *Provider) Close() error { + if p.close == nil { + return nil + } + return p.close() +} + +type envelope struct { + Ref string `json:"ref"` + Tenant string `json:"tenant,omitempty"` + Value []byte `json:"value"` + Meta map[string]string `json:"meta,omitempty"` +} + +func (p *Provider) secretID(ref string) string { + sum := sha256.Sum256([]byte(ref)) + return cmp.Or(p.prefix, p.scope.Root()) + "-" + hex.EncodeToString(sum[:]) +} + +func (p *Provider) secretName(ref string) string { + return fmt.Sprintf("%s/secrets/%s", p.parentName(), p.secretID(ref)) +} + +func (p *Provider) parentName() string { + return fmt.Sprintf("projects/%s/locations/%s", p.projectID, p.region) +} + +// Read fetches and decodes the latest version of the secret at ref. +func (p *Provider) Read(ctx context.Context, ref string) (secret.Secret, error) { + value, _, err := p.readVersion(ctx, ref, "") + return value, err +} + +// readVersion reads an immutable version, or latest when version is empty. +func (p *Provider) readVersion(ctx context.Context, ref, version string) (secret.Secret, string, error) { + if version != "" { + if _, err := strconv.ParseUint(version, 10, 64); err != nil { + return secret.Secret{}, "", errors.New("secret/gcp: invalid version number") + } + } + version = cmp.Or(version, "latest") + out, err := p.client.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{Name: p.secretName(ref) + "/versions/" + version}) + if err != nil { + if status.Code(err) == codes.NotFound { + return secret.Secret{}, "", fmt.Errorf("secret/gcp: read %q: %w", ref, secret.ErrNotFound) + } + return secret.Secret{}, "", fmt.Errorf("secret/gcp: read %q: %w", ref, err) + } + if out.Payload == nil { + return secret.Secret{}, "", fmt.Errorf("secret/gcp: read %q: empty secret", ref) + } + var env envelope + if err := json.Unmarshal(out.Payload.Data, &env); err != nil { + return secret.Secret{}, "", fmt.Errorf("secret/gcp: read %q: decode: %w", ref, err) + } + if env.Ref != ref || env.Value == nil { + return secret.Secret{}, "", fmt.Errorf("secret/gcp: read %q: not a matching managed envelope", ref) + } + number, err := p.versionNumber(ref, out.Name) + if err != nil { + return secret.Secret{}, "", err + } + return secret.Secret{Tenant: env.Tenant, Value: env.Value, Meta: env.Meta}, number, nil +} + +// Write creates the secret if absent, otherwise stores a new version. +func (p *Provider) Write(ctx context.Context, ref string, s secret.Secret) error { + _, err := p.writeVersion(ctx, ref, s) + return err +} + +// writeVersion appends a version to the same secret and returns its number. +func (p *Provider) writeVersion(ctx context.Context, ref string, s secret.Secret) (string, error) { + raw, err := json.Marshal(envelope{Ref: ref, Tenant: s.Tenant, Value: s.Value, Meta: s.Meta}) + if err != nil { + return "", fmt.Errorf("secret/gcp: write %q: encode: %w", ref, err) + } + name := p.secretName(ref) + sm, err := p.client.GetSecret(ctx, &secretmanagerpb.GetSecretRequest{Name: name}) + if status.Code(err) == codes.NotFound { + sm, err = p.client.CreateSecret(ctx, &secretmanagerpb.CreateSecretRequest{ + Parent: p.parentName(), + SecretId: p.secretID(ref), + Secret: &secretmanagerpb.Secret{ + Labels: map[string]string{managedLabel: p.scope.Root()}, + Annotations: map[string]string{p.refAnnotation(): ref}, + }, + }) + if status.Code(err) == codes.AlreadyExists { + sm, err = p.client.GetSecret(ctx, &secretmanagerpb.GetSecretRequest{Name: name}) + } + } + if err != nil { + return "", fmt.Errorf("secret/gcp: prepare %q: %w", ref, err) + } + if err := p.managed(sm, ref); err != nil { + return "", fmt.Errorf("secret/gcp: write %q: %w", ref, err) + } + version, err := p.client.AddSecretVersion(ctx, &secretmanagerpb.AddSecretVersionRequest{ + Parent: name, Payload: &secretmanagerpb.SecretPayload{Data: raw}, + }) + if err != nil { + return "", fmt.Errorf("secret/gcp: write %q: %w", ref, err) + } + return p.versionNumber(ref, version.Name) +} + +// RotateSecret adds a version to the same secret, then destroys the old version. +// A failed append leaves the old version intact. Cleanup errors are returned even +// though the new version has already become latest. +func (p *Provider) RotateSecret(ctx context.Context, ref string, s secret.Secret) error { + if s.Tenant == "" { + return errors.New("secret/gcp: tenant is required") + } + if err := p.scope.Check(ref, s.Tenant); err != nil { + return err + } + name := p.secretName(ref) + sm, err := p.client.GetSecret(ctx, &secretmanagerpb.GetSecretRequest{Name: name}) + if status.Code(err) == codes.NotFound { + return secret.ErrNotFound + } + if err != nil { + return fmt.Errorf("secret/gcp: inspect rotation: %w", err) + } + if err := p.managed(sm, ref); err != nil { + return err + } + current, old, err := p.readVersion(ctx, ref, "") + if err != nil { + return err + } + if current.Tenant != "" && current.Tenant != s.Tenant { + return errors.New("secret/gcp: secret belongs to another tenant") + } + raw, err := json.Marshal(envelope{Ref: ref, Tenant: s.Tenant, Value: s.Value, Meta: s.Meta}) + if err != nil { + return fmt.Errorf("secret/gcp: encode rotation: %w", err) + } + version, err := p.client.AddSecretVersion(ctx, &secretmanagerpb.AddSecretVersionRequest{Parent: name, Payload: &secretmanagerpb.SecretPayload{Data: raw}}) + if err != nil { + return fmt.Errorf("secret/gcp: rotate %q: %w", ref, err) + } + next, err := p.versionNumber(ref, version.Name) + if err != nil { + return err + } + if next == old { + return errors.New("secret/gcp: rotation returned the old version") + } + if _, err := p.client.DestroySecretVersion(ctx, &secretmanagerpb.DestroySecretVersionRequest{Name: name + "/versions/" + old}); err != nil { + return fmt.Errorf("secret/gcp: destroy previous version after rotation: %w", err) + } + return nil +} + +// ErrUnmanaged is returned when Provider cannot prove it owns a secret. +var ErrUnmanaged = errors.New("secret/gcp: refusing to modify an unmanaged secret") + +// refAnnotation is the annotation key holding a secret's ref, such as +// "filament-ref". +func (p *Provider) refAnnotation() string { return p.scope.Root() + "-ref" } + +func (p *Provider) managed(sm *secretmanagerpb.Secret, ref string) error { + if sm == nil || sm.Labels[managedLabel] != p.scope.Root() || sm.Annotations[p.refAnnotation()] != ref { + return ErrUnmanaged + } + return nil +} + +// Delete permanently deletes the secret at ref. Deleting a missing ref is a +// no-op. GCP metadata is checked before its irreversible deletion. +func (p *Provider) Delete(ctx context.Context, ref string) error { + if p.prefix == "" && !strings.HasPrefix(ref, p.scope.Prefix) { + return fmt.Errorf("delete %q: %w", ref, ErrUnmanaged) + } + name := p.secretName(ref) + sm, err := p.client.GetSecret(ctx, &secretmanagerpb.GetSecretRequest{Name: name}) + if status.Code(err) == codes.NotFound { + return nil + } + if err != nil { + return fmt.Errorf("secret/gcp: delete %q: inspect: %w", ref, err) + } + if err := p.managed(sm, ref); err != nil { + return fmt.Errorf("secret/gcp: delete %q: %w", ref, err) + } + if err := p.client.DeleteSecret(ctx, &secretmanagerpb.DeleteSecretRequest{Name: name}); err != nil { + if status.Code(err) == codes.NotFound { + return nil + } + return fmt.Errorf("secret/gcp: delete %q: %w", ref, err) + } + return nil +} + +// Project IDs and their numeric project identifiers can name the same resource. +// Read/write requests use the configured project; responses must identify the +// requested regional secret, and only their immutable version number is saved. +func (p *Provider) versionNumber(ref, name string) (string, error) { + parent, version, ok := strings.Cut(name, "/secrets/"+p.secretID(ref)+"/versions/") + if !ok || !strings.HasPrefix(parent, "projects/") || !strings.HasSuffix(parent, "/locations/"+p.region) { + return "", errors.New("secret/gcp: unexpected version name") + } + if n, err := strconv.ParseUint(version, 10, 64); err != nil || n == 0 { + return "", errors.New("secret/gcp: invalid version number") + } + return version, nil +} diff --git a/secret/gcp/gcp_test.go b/secret/gcp/gcp_test.go new file mode 100644 index 0000000..94de3c0 --- /dev/null +++ b/secret/gcp/gcp_test.go @@ -0,0 +1,131 @@ +package gcp + +import ( + "context" + "errors" + "strings" + "testing" + + "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" + "github.com/googleapis/gax-go/v2" + + "github.com/galaxy-io/modules/secret" +) + +type fakeAPI struct { + API + ref string + read *secretmanagerpb.AccessSecretVersionRequest + add *secretmanagerpb.AddSecretVersionRequest + destroy *secretmanagerpb.DestroySecretVersionRequest + addErr error + destroyErr error + calls []string +} + +func (f *fakeAPI) GetSecret(_ context.Context, in *secretmanagerpb.GetSecretRequest, _ ...gax.CallOption) (*secretmanagerpb.Secret, error) { + return &secretmanagerpb.Secret{Name: in.Name, Labels: map[string]string{managedLabel: "filament"}, Annotations: map[string]string{"filament-ref": f.ref}}, nil +} + +func (f *fakeAPI) AddSecretVersion(_ context.Context, in *secretmanagerpb.AddSecretVersionRequest, _ ...gax.CallOption) (*secretmanagerpb.SecretVersion, error) { + f.calls = append(f.calls, "add") + if f.addErr != nil { + return nil, f.addErr + } + f.add = in + return &secretmanagerpb.SecretVersion{Name: in.Parent + "/versions/7"}, nil +} + +func (f *fakeAPI) AccessSecretVersion(_ context.Context, in *secretmanagerpb.AccessSecretVersionRequest, _ ...gax.CallOption) (*secretmanagerpb.AccessSecretVersionResponse, error) { + f.calls = append(f.calls, "read") + f.read = in + if f.add == nil { + return &secretmanagerpb.AccessSecretVersionResponse{Name: strings.TrimSuffix(in.Name, "latest") + "6", Payload: &secretmanagerpb.SecretPayload{Data: []byte(`{"ref":"` + f.ref + `","tenant":"tenant","value":"b2xk"}`)}}, nil + } + return &secretmanagerpb.AccessSecretVersionResponse{Name: strings.Replace(f.add.Parent, "projects/project/", "projects/123456/", 1) + "/versions/7", Payload: f.add.Payload}, nil +} + +var scope = secret.Scope{Prefix: "filament/"} + +func TestImmutableVersionsKeepSecretName(t *testing.T) { + ref := scope.Ref("tenant", "connection", "token", "v1") + api := &fakeAPI{ref: ref} + p, err := New(api, scope, "project", "us-central1") + if err != nil { + t.Fatal(err) + } + version, err := p.writeVersion(t.Context(), ref, secret.Secret{Tenant: "tenant", Value: []byte("rotated"), Meta: map[string]string{"oauth_format": "v1"}}) + if err != nil || version != "7" { + t.Fatalf("write version: %s %v", version, err) + } + if api.add.Parent != p.secretName(ref) { + t.Fatal("secret identity changed") + } + value, got, err := p.readVersion(t.Context(), ref, version) + if err != nil || got != version || value.Tenant != "tenant" || string(value.Value) != "rotated" || value.Meta["oauth_format"] != "v1" { + t.Fatalf("roundtrip: %v", err) + } + if api.read.Name != p.secretName(ref)+"/versions/7" { + t.Fatal("read latest instead of pinned version") + } + for _, invalid := range []string{"latest", "other/versions/7", "-1"} { + if _, _, err := p.readVersion(t.Context(), ref, invalid); err == nil { + t.Fatalf("accepted invalid version %s", invalid) + } + } +} + +func (f *fakeAPI) DestroySecretVersion(_ context.Context, in *secretmanagerpb.DestroySecretVersionRequest, _ ...gax.CallOption) (*secretmanagerpb.SecretVersion, error) { + f.calls = append(f.calls, "destroy") + f.destroy = in + return &secretmanagerpb.SecretVersion{Name: in.Name}, f.destroyErr +} + +func TestRotationDestroysPreviousVersion(t *testing.T) { + ref := scope.Ref("tenant", "connection", "token", "v1") + api := &fakeAPI{ref: ref} + p, err := New(api, scope, "project", "us-central1") + if err != nil { + t.Fatal(err) + } + if err := p.RotateSecret(t.Context(), ref, secret.Secret{Tenant: "tenant", Value: []byte("rotated")}); err != nil { + t.Fatal(err) + } + if api.add.Parent != p.secretName(ref) || api.destroy.Name != p.secretName(ref)+"/versions/6" { + t.Fatal("rotation changed identity or destroyed wrong version") + } + if strings.Join(api.calls, ",") != "read,add,destroy" { + t.Fatal("old version destroyed before successful append") + } +} + +func TestFailedAppendDoesNotDestroyOldVersion(t *testing.T) { + ref := scope.Ref("tenant", "connection", "token", "v1") + api := &fakeAPI{ref: ref, addErr: errors.New("unavailable")} + p, err := New(api, scope, "project", "us-central1") + if err != nil { + t.Fatal(err) + } + if err := p.RotateSecret(t.Context(), ref, secret.Secret{Tenant: "tenant", Value: []byte("rotated")}); err == nil { + t.Fatal("append error hidden") + } + if api.destroy != nil { + t.Fatal("destroyed old version after append failure") + } +} + +func TestRotationReportsCleanupFailure(t *testing.T) { + ref := scope.Ref("tenant", "connection", "token", "v1") + cleanupErr := errors.New("destroy unavailable") + api := &fakeAPI{ref: ref, destroyErr: cleanupErr} + p, err := New(api, scope, "project", "us-central1") + if err != nil { + t.Fatal(err) + } + if err := p.RotateSecret(t.Context(), ref, secret.Secret{Tenant: "tenant", Value: []byte("rotated")}); !errors.Is(err, cleanupErr) { + t.Fatal("cleanup failure hidden") + } + if api.add == nil || api.destroy == nil { + t.Fatal("rotation did not attempt cleanup after append") + } +} diff --git a/secret/go.mod b/secret/go.mod new file mode 100644 index 0000000..42de911 --- /dev/null +++ b/secret/go.mod @@ -0,0 +1,56 @@ +module github.com/galaxy-io/modules/secret + +go 1.26.4 + +require ( + cloud.google.com/go/secretmanager v1.23.1 + github.com/aws/aws-sdk-go-v2 v1.47.5 + github.com/aws/aws-sdk-go-v2/config v1.33.13 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.50.8 + github.com/googleapis/gax-go/v2 v2.27.0 + google.golang.org/api v0.310.0 + google.golang.org/grpc v1.85.0 +) + +require ( + cloud.google.com/go/auth v0.24.1-0.20261001053825-dbc26066f70a // indirect + cloud.google.com/go/auth/oauth2adapt v0.3.0 // indirect + cloud.google.com/go/compute/metadata v0.10.0 // indirect + cloud.google.com/go/iam v1.14.0 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.20.13 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.12.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.39.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.51.6 // indirect + github.com/aws/smithy-go v1.28.7 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/s2a-go v0.1.11 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.24 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/net v0.61.0 // indirect + golang.org/x/oauth2 v0.37.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.16.0 // indirect + google.golang.org/genproto v0.0.0-20261012220534-4341edf3f941 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b // indirect + google.golang.org/protobuf v1.36.12 // indirect +) diff --git a/secret/go.sum b/secret/go.sum new file mode 100644 index 0000000..fe233a8 --- /dev/null +++ b/secret/go.sum @@ -0,0 +1,122 @@ +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.24.1-0.20261001053825-dbc26066f70a h1:jW1ZLFy9oa5ai8LOI40z6s+DL1cO/L1KSm9hVGnibn8= +cloud.google.com/go/auth v0.24.1-0.20261001053825-dbc26066f70a/go.mod h1:1dmBXa9xfBvBr39ZnDUDkaaA91ccq9CcqENDgJA4d7c= +cloud.google.com/go/auth/oauth2adapt v0.3.0 h1:FY8oSZpCYoUNv6QxVODuMjQz4IlSOVeiQtZ08vLPz88= +cloud.google.com/go/auth/oauth2adapt v0.3.0/go.mod h1:7+2uCm7++XFO+/lN06c2HXpDXb/NMNn2/UwyBPbTnkk= +cloud.google.com/go/compute/metadata v0.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwSA7Ej6LAvvtY= +cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk= +cloud.google.com/go/iam v1.14.0 h1:YRIB1/4xxwJ+NfI6RQr1WaOoHHeV0MizJEj940nBXJA= +cloud.google.com/go/iam v1.14.0/go.mod h1:JTPZfbHdGXJrOYREtkRoFzCDGaBdbA7ILLKO/DDuav0= +cloud.google.com/go/secretmanager v1.23.1 h1:i7GMED6jHSJS72VQbwyBsEw+vsHKbI18MJFEZ33t9N0= +cloud.google.com/go/secretmanager v1.23.1/go.mod h1:S2EZCRLLtM7DEzGr73o5IvpmjCBacfzl0MXXyT4x//c= +github.com/aws/aws-sdk-go-v2 v1.47.5 h1:5yA+A+BiXbDq1ZLbYwPViwhEGDMdR0MEhL8Jh2LnLS4= +github.com/aws/aws-sdk-go-v2 v1.47.5/go.mod h1:iNIkogrRvFwm+1OQIUOP2lPBtlXZboOIUWiCa+pHEMw= +github.com/aws/aws-sdk-go-v2/config v1.33.13 h1:u52x6ISQOx172tpwM0VRxhJzEsTJYpMcEYUGM2A1wrw= +github.com/aws/aws-sdk-go-v2/config v1.33.13/go.mod h1:/ixtz6XPHb42zekjQ0LqD9XjTpTysNWhIGwufY1OzI4= +github.com/aws/aws-sdk-go-v2/credentials v1.20.13 h1:j4z41OtspmQ3ftQycaDunJ26b69a9SHfaSVUPqgRv8k= +github.com/aws/aws-sdk-go-v2/credentials v1.20.13/go.mod h1:SgPA1oS3b+dlMxZb618jDmGNscpXSCaxz15igS52OlE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5 h1:iXjy2SFhGYNB1jWZBhQqq8HbhsbPF23w+/zbMvPXsTg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5/go.mod h1:k0RUiBnyXU0AXRJ9tJmY2jQke6XUIX2PNVnWtvgaPtc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8 h1:gzIWc3DTPfPbWKrQnmL6BV6WD7NPD1htxzgdk/bQ/Z4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8/go.mod h1:vvUA5cDAZWGo4R14FZPZfXfJCmgsafOWEq78Kg0D+ug= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8 h1:PWv+vG8aCLS8qd+HPdYhK7Vees90aKHWFFQ1AGYD/8E= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8/go.mod h1:xxPZEpflYHJA12366ZGyq66z9dHPCoeCIu99Jck+MPw= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8 h1:WOp5De9iWdEHDPh2Kb0tBP3vGBm/dVAuRGh4a4tqduo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8/go.mod h1:z8W8YgfhbWl62XqcTtK+CxPrE+iHGRVUOtrrbIOp7ZY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23 h1:MCIR6iZaCKO4UE0hUvoWWLI6JVjppopV64JfAJaMjng= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23/go.mod h1:pphWFS+z0MmIPivj8c/6bATd1Z+Av7Ust75DPdEFsPA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8 h1:+PMpWlJYOn2tm7sm6OZ/ZLd4jStl17T7wjNapd1Rczs= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8/go.mod h1:cnG3xZLGQL4c/bt8pjdzlFtZsW7epBBuNS04a5xMjdo= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.50.8 h1:9ptf/qDxYHsn1VSeW8GuvB8Ddbdg5xzadMdeLO3OM2I= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.50.8/go.mod h1:hdLRXUZ2D6163JpoVeS4Dpz0kny/eC+5BLhH/mrri+c= +github.com/aws/aws-sdk-go-v2/service/signin v1.12.1 h1:WZLC+k/CZx11kZGQDqu9mpA8yhiXGX2I//jhuJE1FDo= +github.com/aws/aws-sdk-go-v2/service/signin v1.12.1/go.mod h1:wxAk0D2oVBSjuXEIzvcKxTZfM90zticdAbh/VD42L0Y= +github.com/aws/aws-sdk-go-v2/service/sso v1.39.1 h1:4VB/dnDo7pmXSLQMszSoBzWndENuc7mzwTpD6lQn77A= +github.com/aws/aws-sdk-go-v2/service/sso v1.39.1/go.mod h1:UGsEYgWz4Gi+46vDQ5zkFLqUgJziyOE2SXjZjgmfmHc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.1 h1:w8yui2ZqdHmGwVBDGvazoIvKw/DnLENBLkYnN8q8iqo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.1/go.mod h1:gJSdIY6YUzDvqQLcMtPVZgBRHrFwzHryvpE8Igzbxps= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.6 h1:BVjKrtRUtSYKztBCCfE1oAB+d3ViKcW2cKZfserOqMQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.6/go.mod h1:eSukFLeLkPMm+1OxDR+F0joauZdyBkF47Ow7MqjZiJw= +github.com/aws/smithy-go v1.28.7 h1:h/n1SHrwVtaDYLz8mR1/Kq4Xh4RaFbFW7bOYckg9mfE= +github.com/aws/smithy-go v1.28.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260819172001-e6e3fd93e4be h1:SWe0x6yfglnxuvOiYgTTnNq7QD/yvqthOh1RBI8Bj8w= +github.com/envoyproxy/go-control-plane/envoy v1.39.1-0.20260819172001-e6e3fd93e4be/go.mod h1:PYEOlng9XcrulfyWpm49jECTPV0LT4q8cO7fLW/xwgk= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/s2a-go v0.1.11 h1:7v1V1TraNGP63WptsAkNqr2w8jgm4vM6A6qx6Lsw9iM= +github.com/google/s2a-go v0.1.11/go.mod h1:pz4tyvwXvJLLbyrkh6FW1eS2zPUXMaTmyNhYtyP2tNw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.24 h1:G9iCJIY+AYVmUskKdjX8FVy7sG+nbzjOQ41cvlmhcvg= +github.com/googleapis/enterprise-certificate-proxy v0.3.24/go.mod h1:hEjCmnoakQzv5ImLUp6alkY7XgLlNoAxwTXmhHHNIsI= +github.com/googleapis/gax-go/v2 v2.27.0 h1:qe2iQOUzxb3fd6x6ewpIEixPQhJ+0Ihc/RRZhKA/2g0= +github.com/googleapis/gax-go/v2 v2.27.0/go.mod h1:RofiqDw3ehcG4L0H1zO3D2Itpwd3/uS/7wdmbNDF1DE= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.310.0 h1:537ccDDmbwuwX1iof3Vtdz9vow6uJCgrSZJWedAWM0I= +google.golang.org/api v0.310.0/go.mod h1:5NUthU+LmGMKzYgRaScb+TTe1Lo7x4A+J7g+Yj8BRzA= +google.golang.org/genproto v0.0.0-20261012220534-4341edf3f941 h1:CXq2XEi5aEyLjjPZIwqrmQH8ZY4ctUwK7uyAx3mjX/U= +google.golang.org/genproto v0.0.0-20261012220534-4341edf3f941/go.mod h1:3gK0ehnhP0V7/x3ypGoLyT3d0n6TSi/EcsphIjHrcz4= +google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941 h1:DS/w3N8rsfrwxwgGSj6WhMsPo7aNQGmDx74J81zd0NQ= +google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941/go.mod h1:AuGAdi9KnXss7xbLxIKhCvk+Ro/SxpET/iS45DNYN+M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.85.0 h1:BPBCfuVRQ1qvBhKDOq9uvQllMvO59rKsYhXpp7Dd8vw= +google.golang.org/grpc v1.85.0/go.mod h1:0rQrOD58yjal/0EaVbGBANindixsCZNQPDV3VKtaKuQ= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/secret/secret.go b/secret/secret.go new file mode 100644 index 0000000..b2698e9 --- /dev/null +++ b/secret/secret.go @@ -0,0 +1,91 @@ +// Package secret resolves opaque references to secret material, keeping +// plaintext out of configs and stores. Providers live in subpackages: datastore +// encrypts into an app's own table, env reads the process environment, and +// the aws and gcp packages wrap the cloud secret managers. +package secret + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// ErrNotFound reports a ref with no secret behind it. +var ErrNotFound = errors.New("secret: not found") + +// Secrets resolves opaque references to secret material. +type Secrets interface { + Read(ctx context.Context, ref string) (Secret, error) + Write(ctx context.Context, ref string, s Secret) error + Delete(ctx context.Context, ref string) error + Name() string +} + +// Secret is plaintext, in-memory only; never logged, never on the bus. +type Secret struct { + Tenant string + Value []byte + Meta map[string]string +} + +// Rotator replaces the value of an existing secret without creating a secret +// or changing its reference. Rotation must not recreate a deleted secret. +// Implementations preserve tenant isolation and encrypt values at rest. +type Rotator interface { + RotateSecret(ctx context.Context, ref string, s Secret) error +} + +// Scope is the namespace an app mints tenant-owned refs under: +// /, such as "filament////v1". +// Refs outside the prefix, such as env-style refs, carry no tenant. +type Scope struct { + // Prefix is the app's root segment with its trailing slash, such as + // "filament/". + Prefix string +} + +// Validate requires a single root segment ending in a slash. +func (s Scope) Validate() error { + root, ok := strings.CutSuffix(s.Prefix, "/") + if !ok || root == "" || strings.Contains(root, "/") { + return fmt.Errorf("secret: scope prefix %q must be one segment followed by a slash", s.Prefix) + } + return nil +} + +// Root is the prefix without its trailing slash, such as "filament". +func (s Scope) Root() string { return strings.TrimSuffix(s.Prefix, "/") } + +// Ref builds the canonical ref for a secret tenant owns. +func (s Scope) Ref(tenant string, parts ...string) string { + return s.Prefix + strings.Join(append([]string{tenant}, parts...), "/") +} + +// Owner returns the tenant a scoped ref belongs to. It reports false for a ref +// outside the scope, and fails for a scoped ref with no tenant segment. +func (s Scope) Owner(ref string) (string, bool, error) { + rest, scoped := strings.CutPrefix(ref, s.Prefix) + if !scoped { + return "", false, nil + } + owner, _, ok := strings.Cut(rest, "/") + if !ok || owner == "" { + return "", true, fmt.Errorf("secret: malformed scoped ref %q", ref) + } + return owner, true, nil +} + +// Check enforces the cross-tenant isolation rule for a ref-keyed store: a ref +// in the scope may only be read or written by its owning tenant. Refs outside +// the scope carry no tenant and pass through. +func (s Scope) Check(ref, tenant string) error { + owner, scoped, err := s.Owner(ref) + if err != nil || !scoped { + return err + } + if owner != tenant { + return fmt.Errorf("secret: ref %q is not owned by tenant %q", ref, tenant) + } + return nil +} diff --git a/secret/secret_test.go b/secret/secret_test.go new file mode 100644 index 0000000..022eb48 --- /dev/null +++ b/secret/secret_test.go @@ -0,0 +1,40 @@ +package secret_test + +import ( + "testing" + + "github.com/galaxy-io/modules/secret" +) + +func TestScope(t *testing.T) { + scope := secret.Scope{Prefix: "filament/"} + ref := scope.Ref("tenant-a", "conn", "token", "v1") + if ref != "filament/tenant-a/conn/token/v1" { + t.Fatalf("ref = %q", ref) + } + if owner, scoped, err := scope.Owner(ref); err != nil || !scoped || owner != "tenant-a" { + t.Fatalf("owner = %q %v %v", owner, scoped, err) + } + if err := scope.Check(ref, "tenant-a"); err != nil { + t.Fatal(err) + } + if err := scope.Check(ref, "tenant-b"); err == nil { + t.Fatal("crossed tenant boundary") + } + if err := scope.Check("postgres-creds/dsn", "tenant-b"); err != nil { + t.Fatal("unscoped ref refused") + } + for _, malformed := range []string{"filament/", "filament/tenant-a", "filament//conn"} { + if err := scope.Check(malformed, "tenant-a"); err == nil { + t.Fatalf("accepted malformed ref %q", malformed) + } + } +} + +func TestScopeValidate(t *testing.T) { + for prefix, ok := range map[string]bool{"filament/": true, "alchemy/": true, "": false, "filament": false, "/": false, "a/b/": false} { + if err := (secret.Scope{Prefix: prefix}).Validate(); (err == nil) != ok { + t.Errorf("Validate(%q) = %v", prefix, err) + } + } +}