From c0754404235702f8072fa3119c90e288de8573c0 Mon Sep 17 00:00:00 2001 From: leonkozlowski Date: Mon, 2 Nov 2026 17:32:10 -0500 Subject: [PATCH 1/8] feat(secret): add secret module with datastore, env, aws and gcp providers --- .gitignore | 2 + .golangci.yaml | 90 ++++++++ justfile | 12 ++ secret/aws/aws.go | 215 +++++++++++++++++++ secret/aws/aws_test.go | 97 +++++++++ secret/aws/go.mod | 25 +++ secret/aws/go.sum | 30 +++ secret/datastore/datastore.go | 186 ++++++++++++++++ secret/datastore/datastore_test.go | 143 +++++++++++++ secret/env/env.go | 53 +++++ secret/gcp/gcp.go | 326 +++++++++++++++++++++++++++++ secret/gcp/gcp_test.go | 131 ++++++++++++ secret/gcp/go.mod | 42 ++++ secret/gcp/go.sum | 92 ++++++++ secret/go.mod | 56 +++++ secret/go.sum | 122 +++++++++++ secret/secret.go | 91 ++++++++ secret/secret_test.go | 40 ++++ 18 files changed, 1753 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yaml create mode 100644 justfile create mode 100644 secret/aws/aws.go create mode 100644 secret/aws/aws_test.go create mode 100644 secret/aws/go.mod create mode 100644 secret/aws/go.sum create mode 100644 secret/datastore/datastore.go create mode 100644 secret/datastore/datastore_test.go create mode 100644 secret/env/env.go create mode 100644 secret/gcp/gcp.go create mode 100644 secret/gcp/gcp_test.go create mode 100644 secret/gcp/go.mod create mode 100644 secret/gcp/go.sum create mode 100644 secret/go.mod create mode 100644 secret/go.sum create mode 100644 secret/secret.go create mode 100644 secret/secret_test.go 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/.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/justfile b/justfile new file mode 100644 index 0000000..6ff15fb --- /dev/null +++ b/justfile @@ -0,0 +1,12 @@ +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 + +# 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/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/aws/go.mod b/secret/aws/go.mod new file mode 100644 index 0000000..3d57cf3 --- /dev/null +++ b/secret/aws/go.mod @@ -0,0 +1,25 @@ +module github.com/galaxy-io/modules/secret/aws + +go 1.26.4 + +require ( + 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/galaxy-io/modules/secret v0.1.0 +) + +require ( + 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 +) diff --git a/secret/aws/go.sum b/secret/aws/go.sum new file mode 100644 index 0000000..d182c4d --- /dev/null +++ b/secret/aws/go.sum @@ -0,0 +1,30 @@ +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= 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/gcp/go.mod b/secret/gcp/go.mod new file mode 100644 index 0000000..7ed7284 --- /dev/null +++ b/secret/gcp/go.mod @@ -0,0 +1,42 @@ +module github.com/galaxy-io/modules/secret/gcp + +go 1.26.4 + +require ( + cloud.google.com/go/secretmanager v1.23.1 + github.com/galaxy-io/modules/secret v0.1.0 + 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/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.68.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/gcp/go.sum b/secret/gcp/go.sum new file mode 100644 index 0000000..1bbf3ef --- /dev/null +++ b/secret/gcp/go.sum @@ -0,0 +1,92 @@ +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/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.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= +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/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..74aa27d --- /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 modules 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) + } + } +} From a8737ecf9f9bb184287b5343efd74afb464808ee Mon Sep 17 00:00:00 2001 From: leonkozlowski Date: Mon, 2 Nov 2026 17:46:12 -0500 Subject: [PATCH 2/8] chore: add codeowners --- .github/CODEOWNERS | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .github/CODEOWNERS 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 From 9001d131a92463d5fbb5173aa120d27ba36aa5fa Mon Sep 17 00:00:00 2001 From: leonkozlowski Date: Mon, 2 Nov 2026 17:56:57 -0500 Subject: [PATCH 3/8] feat(secret): add secret module with datastore, env, aws and gcp providers --- .github/workflows/ci.yaml | 49 +++++++++++ .github/workflows/pr-title.yaml | 18 ++++ .github/workflows/release-please.yaml | 29 ++++++ .go-version | 1 + .release-please-manifest.json | 5 ++ justfile | 39 ++++++++ release-please-config.json | 19 ++++ secret/go.mod | 53 ----------- secret/go.sum | 122 -------------------------- 9 files changed, 160 insertions(+), 175 deletions(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/pr-title.yaml create mode 100644 .github/workflows/release-please.yaml create mode 100644 .go-version create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json delete mode 100644 secret/go.sum 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/.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/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..243be53 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,5 @@ +{ + "secret": "0.0.0", + "secret/aws": "0.0.0", + "secret/gcp": "0.0.0" +} diff --git a/justfile b/justfile index 6ff15fb..e2f0d36 100644 --- a/justfile +++ b/justfile @@ -5,6 +5,45 @@ default: _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 { "" }) + "./...")) diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..f98c1cf --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,19 @@ +{ + "$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", + "exclude-paths": ["secret/aws", "secret/gcp"] + }, + "secret/aws": { + "component": "secret/aws" + }, + "secret/gcp": { + "component": "secret/gcp" + } + } +} diff --git a/secret/go.mod b/secret/go.mod index 42de911..991aa72 100644 --- a/secret/go.mod +++ b/secret/go.mod @@ -1,56 +1,3 @@ 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 deleted file mode 100644 index fe233a8..0000000 --- a/secret/go.sum +++ /dev/null @@ -1,122 +0,0 @@ -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= From 9677b8866b2f09062ba66e21330ebbefffeb99aa Mon Sep 17 00:00:00 2001 From: leonkozlowski Date: Mon, 2 Nov 2026 18:04:49 -0500 Subject: [PATCH 4/8] feat: unify the version --- .release-please-manifest.json | 4 +-- release-please-config.json | 9 +----- secret/aws/go.mod | 25 ----------------- secret/aws/go.sum | 30 -------------------- secret/gcp/go.mod | 42 --------------------------- secret/go.mod | 53 +++++++++++++++++++++++++++++++++++ secret/{gcp => }/go.sum | 34 ++++++++++++++++++++-- secret/secret.go | 2 +- 8 files changed, 88 insertions(+), 111 deletions(-) delete mode 100644 secret/aws/go.mod delete mode 100644 secret/aws/go.sum delete mode 100644 secret/gcp/go.mod rename secret/{gcp => }/go.sum (72%) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 243be53..2af8910 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,5 +1,3 @@ { - "secret": "0.0.0", - "secret/aws": "0.0.0", - "secret/gcp": "0.0.0" + "secret": "0.0.0" } diff --git a/release-please-config.json b/release-please-config.json index f98c1cf..1214952 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -6,14 +6,7 @@ "tag-separator": "/", "packages": { "secret": { - "component": "secret", - "exclude-paths": ["secret/aws", "secret/gcp"] - }, - "secret/aws": { - "component": "secret/aws" - }, - "secret/gcp": { - "component": "secret/gcp" + "component": "secret" } } } diff --git a/secret/aws/go.mod b/secret/aws/go.mod deleted file mode 100644 index 3d57cf3..0000000 --- a/secret/aws/go.mod +++ /dev/null @@ -1,25 +0,0 @@ -module github.com/galaxy-io/modules/secret/aws - -go 1.26.4 - -require ( - 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/galaxy-io/modules/secret v0.1.0 -) - -require ( - 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 -) diff --git a/secret/aws/go.sum b/secret/aws/go.sum deleted file mode 100644 index d182c4d..0000000 --- a/secret/aws/go.sum +++ /dev/null @@ -1,30 +0,0 @@ -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= diff --git a/secret/gcp/go.mod b/secret/gcp/go.mod deleted file mode 100644 index 7ed7284..0000000 --- a/secret/gcp/go.mod +++ /dev/null @@ -1,42 +0,0 @@ -module github.com/galaxy-io/modules/secret/gcp - -go 1.26.4 - -require ( - cloud.google.com/go/secretmanager v1.23.1 - github.com/galaxy-io/modules/secret v0.1.0 - 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/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.68.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.mod b/secret/go.mod index 991aa72..42de911 100644 --- a/secret/go.mod +++ b/secret/go.mod @@ -1,3 +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/gcp/go.sum b/secret/go.sum similarity index 72% rename from secret/gcp/go.sum rename to secret/go.sum index 1bbf3ef..fe233a8 100644 --- a/secret/gcp/go.sum +++ b/secret/go.sum @@ -10,6 +10,36 @@ 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= @@ -44,8 +74,8 @@ github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWD 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.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= +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= diff --git a/secret/secret.go b/secret/secret.go index 74aa27d..b2698e9 100644 --- a/secret/secret.go +++ b/secret/secret.go @@ -1,7 +1,7 @@ // 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 modules wrap the cloud secret managers. +// the aws and gcp packages wrap the cloud secret managers. package secret import ( From 17f7d9c48c8655087780d60e58e61b896c592121 Mon Sep 17 00:00:00 2001 From: leonkozlowski Date: Mon, 2 Nov 2026 18:10:04 -0500 Subject: [PATCH 5/8] docs: little readme --- README.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ff5c58e..812a08e 100644 --- a/README.md +++ b/README.md @@ -1 +1,31 @@ -# modules \ No newline at end of file +
+ +[![CI](https://github.com/galaxy-io/modules/actions/workflows/ci.yaml/badge.svg)](https://github.com/galaxy-io/modules/actions/workflows/ci.yaml) + +
+ +# Modules + +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. | + +## 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 +``` From 3e4dea0c8d6cd21ca6dcf3b8d458e59675ff9734 Mon Sep 17 00:00:00 2001 From: leonkozlowski Date: Mon, 2 Nov 2026 18:21:04 -0500 Subject: [PATCH 6/8] docs: cleanup --- README.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 812a08e..80c0ce6 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,12 @@ -
+# Modules [![CI](https://github.com/galaxy-io/modules/actions/workflows/ci.yaml/badge.svg)](https://github.com/galaxy-io/modules/actions/workflows/ci.yaml) -
- -# Modules - 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. | +| 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 From 9d44f5e07946ad0887933d2d18e05220538c89db Mon Sep 17 00:00:00 2001 From: leonkozlowski Date: Mon, 2 Nov 2026 18:21:43 -0500 Subject: [PATCH 7/8] docs: name --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 80c0ce6..76fb380 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Modules +# 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) From 3889b7344cc112b59b128fb0c89cf78bff8b6d46 Mon Sep 17 00:00:00 2001 From: leonkozlowski Date: Tue, 3 Nov 2026 11:47:53 -0500 Subject: [PATCH 8/8] feat: port identity package to modules --- .release-please-manifest.json | 1 + README.md | 1 + .../auth/v1/authv1connect/service.connect.go | 519 +++++++++++ identity/auth/v1/members.pb.go | 628 +++++++++++++ identity/auth/v1/service.pb.go | 141 +++ identity/auth/v1/service_accounts.pb.go | 575 ++++++++++++ identity/auth/v1/session.pb.go | 834 ++++++++++++++++++ identity/buf.gen.yaml | 13 + identity/buf.yaml | 3 + identity/go.mod | 41 + identity/go.sum | 99 +++ identity/identity.go | 136 +++ identity/keycloak/admin.go | 413 +++++++++ identity/keycloak/bootstrap.go | 379 ++++++++ identity/keycloak/keycloak.go | 320 +++++++ identity/keycloak/members.go | 284 ++++++ identity/keycloak/service_accounts.go | 243 +++++ identity/keycloak/session.go | 290 ++++++ identity/proto/auth/v1/members.proto | 56 ++ identity/proto/auth/v1/service.proto | 41 + identity/proto/auth/v1/service_accounts.proto | 52 ++ identity/proto/auth/v1/session.proto | 83 ++ identity/proxy/proxy.go | 62 ++ identity/proxy/proxy_test.go | 70 ++ identity/zitadel/bootstrap.go | 226 +++++ identity/zitadel/members.go | 228 +++++ identity/zitadel/service_accounts.go | 238 +++++ identity/zitadel/session.go | 303 +++++++ identity/zitadel/zitadel.go | 256 ++++++ release-please-config.json | 3 + 30 files changed, 6538 insertions(+) create mode 100644 identity/auth/v1/authv1connect/service.connect.go create mode 100644 identity/auth/v1/members.pb.go create mode 100644 identity/auth/v1/service.pb.go create mode 100644 identity/auth/v1/service_accounts.pb.go create mode 100644 identity/auth/v1/session.pb.go create mode 100644 identity/buf.gen.yaml create mode 100644 identity/buf.yaml create mode 100644 identity/go.mod create mode 100644 identity/go.sum create mode 100644 identity/identity.go create mode 100644 identity/keycloak/admin.go create mode 100644 identity/keycloak/bootstrap.go create mode 100644 identity/keycloak/keycloak.go create mode 100644 identity/keycloak/members.go create mode 100644 identity/keycloak/service_accounts.go create mode 100644 identity/keycloak/session.go create mode 100644 identity/proto/auth/v1/members.proto create mode 100644 identity/proto/auth/v1/service.proto create mode 100644 identity/proto/auth/v1/service_accounts.proto create mode 100644 identity/proto/auth/v1/session.proto create mode 100644 identity/proxy/proxy.go create mode 100644 identity/proxy/proxy_test.go create mode 100644 identity/zitadel/bootstrap.go create mode 100644 identity/zitadel/members.go create mode 100644 identity/zitadel/service_accounts.go create mode 100644 identity/zitadel/session.go create mode 100644 identity/zitadel/zitadel.go diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2af8910..7515fc9 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,4 @@ { + "identity": "0.0.0", "secret": "0.0.0" } diff --git a/README.md b/README.md index 76fb380..65c1035 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Shared Go building blocks for Galaxy apps. Each module is versioned on its own. | Module | Description | | | --- | --- | --- | +| [`identity`](identity) | Authentication with Keycloak, Zitadel, or a trusted gateway. | [![Go Reference](https://pkg.go.dev/badge/github.com/galaxy-io/modules/identity.svg)](https://pkg.go.dev/github.com/galaxy-io/modules/identity) [![Version](https://img.shields.io/github/v/tag/galaxy-io/modules?filter=identity/*&label=version)](https://github.com/galaxy-io/modules/tags) | | [`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 diff --git a/identity/auth/v1/authv1connect/service.connect.go b/identity/auth/v1/authv1connect/service.connect.go new file mode 100644 index 0000000..a3b5a13 --- /dev/null +++ b/identity/auth/v1/authv1connect/service.connect.go @@ -0,0 +1,519 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: auth/v1/service.proto + +package authv1connect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + v1 "github.com/galaxy-io/modules/identity/auth/v1" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // AuthServiceName is the fully-qualified name of the AuthService service. + AuthServiceName = "auth.v1.AuthService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // AuthServiceGetAuthConfigProcedure is the fully-qualified name of the AuthService's GetAuthConfig + // RPC. + AuthServiceGetAuthConfigProcedure = "/auth.v1.AuthService/GetAuthConfig" + // AuthServiceLoginProcedure is the fully-qualified name of the AuthService's Login RPC. + AuthServiceLoginProcedure = "/auth.v1.AuthService/Login" + // AuthServiceGetTokenProcedure is the fully-qualified name of the AuthService's GetToken RPC. + AuthServiceGetTokenProcedure = "/auth.v1.AuthService/GetToken" + // AuthServiceLogoutProcedure is the fully-qualified name of the AuthService's Logout RPC. + AuthServiceLogoutProcedure = "/auth.v1.AuthService/Logout" + // AuthServiceRegisterProcedure is the fully-qualified name of the AuthService's Register RPC. + AuthServiceRegisterProcedure = "/auth.v1.AuthService/Register" + // AuthServiceAcceptInviteProcedure is the fully-qualified name of the AuthService's AcceptInvite + // RPC. + AuthServiceAcceptInviteProcedure = "/auth.v1.AuthService/AcceptInvite" + // AuthServiceGetSessionProcedure is the fully-qualified name of the AuthService's GetSession RPC. + AuthServiceGetSessionProcedure = "/auth.v1.AuthService/GetSession" + // AuthServiceListMembersProcedure is the fully-qualified name of the AuthService's ListMembers RPC. + AuthServiceListMembersProcedure = "/auth.v1.AuthService/ListMembers" + // AuthServiceInviteMemberProcedure is the fully-qualified name of the AuthService's InviteMember + // RPC. + AuthServiceInviteMemberProcedure = "/auth.v1.AuthService/InviteMember" + // AuthServiceSetMemberRoleProcedure is the fully-qualified name of the AuthService's SetMemberRole + // RPC. + AuthServiceSetMemberRoleProcedure = "/auth.v1.AuthService/SetMemberRole" + // AuthServiceRemoveMemberProcedure is the fully-qualified name of the AuthService's RemoveMember + // RPC. + AuthServiceRemoveMemberProcedure = "/auth.v1.AuthService/RemoveMember" + // AuthServiceListServiceAccountsProcedure is the fully-qualified name of the AuthService's + // ListServiceAccounts RPC. + AuthServiceListServiceAccountsProcedure = "/auth.v1.AuthService/ListServiceAccounts" + // AuthServiceCreateServiceAccountProcedure is the fully-qualified name of the AuthService's + // CreateServiceAccount RPC. + AuthServiceCreateServiceAccountProcedure = "/auth.v1.AuthService/CreateServiceAccount" + // AuthServiceRotateServiceAccountSecretProcedure is the fully-qualified name of the AuthService's + // RotateServiceAccountSecret RPC. + AuthServiceRotateServiceAccountSecretProcedure = "/auth.v1.AuthService/RotateServiceAccountSecret" + // AuthServiceRemoveServiceAccountProcedure is the fully-qualified name of the AuthService's + // RemoveServiceAccount RPC. + AuthServiceRemoveServiceAccountProcedure = "/auth.v1.AuthService/RemoveServiceAccount" +) + +// AuthServiceClient is a client for the auth.v1.AuthService service. +type AuthServiceClient interface { + // Session; pre-token, public. An empty issuer means auth is disabled. + GetAuthConfig(context.Context, *connect.Request[v1.GetAuthConfigRequest]) (*connect.Response[v1.GetAuthConfigResponse], error) + Login(context.Context, *connect.Request[v1.LoginRequest]) (*connect.Response[v1.LoginResponse], error) + GetToken(context.Context, *connect.Request[v1.GetTokenRequest]) (*connect.Response[v1.GetTokenResponse], error) + Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) + Register(context.Context, *connect.Request[v1.RegisterRequest]) (*connect.Response[v1.RegisterResponse], error) + AcceptInvite(context.Context, *connect.Request[v1.AcceptInviteRequest]) (*connect.Response[v1.AcceptInviteResponse], error) + // Session; authenticated. The UI's sign-in check. + GetSession(context.Context, *connect.Request[v1.GetSessionRequest]) (*connect.Response[v1.GetSessionResponse], error) + // Members; authenticated tenant administration. + ListMembers(context.Context, *connect.Request[v1.ListMembersRequest]) (*connect.Response[v1.ListMembersResponse], error) + InviteMember(context.Context, *connect.Request[v1.InviteMemberRequest]) (*connect.Response[v1.InviteMemberResponse], error) + SetMemberRole(context.Context, *connect.Request[v1.SetMemberRoleRequest]) (*connect.Response[v1.SetMemberRoleResponse], error) + RemoveMember(context.Context, *connect.Request[v1.RemoveMemberRequest]) (*connect.Response[v1.RemoveMemberResponse], error) + // Service accounts; authenticated tenant administration. Secrets are only + // returned by create and rotate and cannot be retrieved later. + ListServiceAccounts(context.Context, *connect.Request[v1.ListServiceAccountsRequest]) (*connect.Response[v1.ListServiceAccountsResponse], error) + CreateServiceAccount(context.Context, *connect.Request[v1.CreateServiceAccountRequest]) (*connect.Response[v1.CreateServiceAccountResponse], error) + RotateServiceAccountSecret(context.Context, *connect.Request[v1.RotateServiceAccountSecretRequest]) (*connect.Response[v1.RotateServiceAccountSecretResponse], error) + RemoveServiceAccount(context.Context, *connect.Request[v1.RemoveServiceAccountRequest]) (*connect.Response[v1.RemoveServiceAccountResponse], error) +} + +// NewAuthServiceClient constructs a client for the auth.v1.AuthService service. By default, it uses +// the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewAuthServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) AuthServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + authServiceMethods := v1.File_auth_v1_service_proto.Services().ByName("AuthService").Methods() + return &authServiceClient{ + getAuthConfig: connect.NewClient[v1.GetAuthConfigRequest, v1.GetAuthConfigResponse]( + httpClient, + baseURL+AuthServiceGetAuthConfigProcedure, + connect.WithSchema(authServiceMethods.ByName("GetAuthConfig")), + connect.WithClientOptions(opts...), + ), + login: connect.NewClient[v1.LoginRequest, v1.LoginResponse]( + httpClient, + baseURL+AuthServiceLoginProcedure, + connect.WithSchema(authServiceMethods.ByName("Login")), + connect.WithClientOptions(opts...), + ), + getToken: connect.NewClient[v1.GetTokenRequest, v1.GetTokenResponse]( + httpClient, + baseURL+AuthServiceGetTokenProcedure, + connect.WithSchema(authServiceMethods.ByName("GetToken")), + connect.WithClientOptions(opts...), + ), + logout: connect.NewClient[v1.LogoutRequest, v1.LogoutResponse]( + httpClient, + baseURL+AuthServiceLogoutProcedure, + connect.WithSchema(authServiceMethods.ByName("Logout")), + connect.WithClientOptions(opts...), + ), + register: connect.NewClient[v1.RegisterRequest, v1.RegisterResponse]( + httpClient, + baseURL+AuthServiceRegisterProcedure, + connect.WithSchema(authServiceMethods.ByName("Register")), + connect.WithClientOptions(opts...), + ), + acceptInvite: connect.NewClient[v1.AcceptInviteRequest, v1.AcceptInviteResponse]( + httpClient, + baseURL+AuthServiceAcceptInviteProcedure, + connect.WithSchema(authServiceMethods.ByName("AcceptInvite")), + connect.WithClientOptions(opts...), + ), + getSession: connect.NewClient[v1.GetSessionRequest, v1.GetSessionResponse]( + httpClient, + baseURL+AuthServiceGetSessionProcedure, + connect.WithSchema(authServiceMethods.ByName("GetSession")), + connect.WithClientOptions(opts...), + ), + listMembers: connect.NewClient[v1.ListMembersRequest, v1.ListMembersResponse]( + httpClient, + baseURL+AuthServiceListMembersProcedure, + connect.WithSchema(authServiceMethods.ByName("ListMembers")), + connect.WithClientOptions(opts...), + ), + inviteMember: connect.NewClient[v1.InviteMemberRequest, v1.InviteMemberResponse]( + httpClient, + baseURL+AuthServiceInviteMemberProcedure, + connect.WithSchema(authServiceMethods.ByName("InviteMember")), + connect.WithClientOptions(opts...), + ), + setMemberRole: connect.NewClient[v1.SetMemberRoleRequest, v1.SetMemberRoleResponse]( + httpClient, + baseURL+AuthServiceSetMemberRoleProcedure, + connect.WithSchema(authServiceMethods.ByName("SetMemberRole")), + connect.WithClientOptions(opts...), + ), + removeMember: connect.NewClient[v1.RemoveMemberRequest, v1.RemoveMemberResponse]( + httpClient, + baseURL+AuthServiceRemoveMemberProcedure, + connect.WithSchema(authServiceMethods.ByName("RemoveMember")), + connect.WithClientOptions(opts...), + ), + listServiceAccounts: connect.NewClient[v1.ListServiceAccountsRequest, v1.ListServiceAccountsResponse]( + httpClient, + baseURL+AuthServiceListServiceAccountsProcedure, + connect.WithSchema(authServiceMethods.ByName("ListServiceAccounts")), + connect.WithClientOptions(opts...), + ), + createServiceAccount: connect.NewClient[v1.CreateServiceAccountRequest, v1.CreateServiceAccountResponse]( + httpClient, + baseURL+AuthServiceCreateServiceAccountProcedure, + connect.WithSchema(authServiceMethods.ByName("CreateServiceAccount")), + connect.WithClientOptions(opts...), + ), + rotateServiceAccountSecret: connect.NewClient[v1.RotateServiceAccountSecretRequest, v1.RotateServiceAccountSecretResponse]( + httpClient, + baseURL+AuthServiceRotateServiceAccountSecretProcedure, + connect.WithSchema(authServiceMethods.ByName("RotateServiceAccountSecret")), + connect.WithClientOptions(opts...), + ), + removeServiceAccount: connect.NewClient[v1.RemoveServiceAccountRequest, v1.RemoveServiceAccountResponse]( + httpClient, + baseURL+AuthServiceRemoveServiceAccountProcedure, + connect.WithSchema(authServiceMethods.ByName("RemoveServiceAccount")), + connect.WithClientOptions(opts...), + ), + } +} + +// authServiceClient implements AuthServiceClient. +type authServiceClient struct { + getAuthConfig *connect.Client[v1.GetAuthConfigRequest, v1.GetAuthConfigResponse] + login *connect.Client[v1.LoginRequest, v1.LoginResponse] + getToken *connect.Client[v1.GetTokenRequest, v1.GetTokenResponse] + logout *connect.Client[v1.LogoutRequest, v1.LogoutResponse] + register *connect.Client[v1.RegisterRequest, v1.RegisterResponse] + acceptInvite *connect.Client[v1.AcceptInviteRequest, v1.AcceptInviteResponse] + getSession *connect.Client[v1.GetSessionRequest, v1.GetSessionResponse] + listMembers *connect.Client[v1.ListMembersRequest, v1.ListMembersResponse] + inviteMember *connect.Client[v1.InviteMemberRequest, v1.InviteMemberResponse] + setMemberRole *connect.Client[v1.SetMemberRoleRequest, v1.SetMemberRoleResponse] + removeMember *connect.Client[v1.RemoveMemberRequest, v1.RemoveMemberResponse] + listServiceAccounts *connect.Client[v1.ListServiceAccountsRequest, v1.ListServiceAccountsResponse] + createServiceAccount *connect.Client[v1.CreateServiceAccountRequest, v1.CreateServiceAccountResponse] + rotateServiceAccountSecret *connect.Client[v1.RotateServiceAccountSecretRequest, v1.RotateServiceAccountSecretResponse] + removeServiceAccount *connect.Client[v1.RemoveServiceAccountRequest, v1.RemoveServiceAccountResponse] +} + +// GetAuthConfig calls auth.v1.AuthService.GetAuthConfig. +func (c *authServiceClient) GetAuthConfig(ctx context.Context, req *connect.Request[v1.GetAuthConfigRequest]) (*connect.Response[v1.GetAuthConfigResponse], error) { + return c.getAuthConfig.CallUnary(ctx, req) +} + +// Login calls auth.v1.AuthService.Login. +func (c *authServiceClient) Login(ctx context.Context, req *connect.Request[v1.LoginRequest]) (*connect.Response[v1.LoginResponse], error) { + return c.login.CallUnary(ctx, req) +} + +// GetToken calls auth.v1.AuthService.GetToken. +func (c *authServiceClient) GetToken(ctx context.Context, req *connect.Request[v1.GetTokenRequest]) (*connect.Response[v1.GetTokenResponse], error) { + return c.getToken.CallUnary(ctx, req) +} + +// Logout calls auth.v1.AuthService.Logout. +func (c *authServiceClient) Logout(ctx context.Context, req *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) { + return c.logout.CallUnary(ctx, req) +} + +// Register calls auth.v1.AuthService.Register. +func (c *authServiceClient) Register(ctx context.Context, req *connect.Request[v1.RegisterRequest]) (*connect.Response[v1.RegisterResponse], error) { + return c.register.CallUnary(ctx, req) +} + +// AcceptInvite calls auth.v1.AuthService.AcceptInvite. +func (c *authServiceClient) AcceptInvite(ctx context.Context, req *connect.Request[v1.AcceptInviteRequest]) (*connect.Response[v1.AcceptInviteResponse], error) { + return c.acceptInvite.CallUnary(ctx, req) +} + +// GetSession calls auth.v1.AuthService.GetSession. +func (c *authServiceClient) GetSession(ctx context.Context, req *connect.Request[v1.GetSessionRequest]) (*connect.Response[v1.GetSessionResponse], error) { + return c.getSession.CallUnary(ctx, req) +} + +// ListMembers calls auth.v1.AuthService.ListMembers. +func (c *authServiceClient) ListMembers(ctx context.Context, req *connect.Request[v1.ListMembersRequest]) (*connect.Response[v1.ListMembersResponse], error) { + return c.listMembers.CallUnary(ctx, req) +} + +// InviteMember calls auth.v1.AuthService.InviteMember. +func (c *authServiceClient) InviteMember(ctx context.Context, req *connect.Request[v1.InviteMemberRequest]) (*connect.Response[v1.InviteMemberResponse], error) { + return c.inviteMember.CallUnary(ctx, req) +} + +// SetMemberRole calls auth.v1.AuthService.SetMemberRole. +func (c *authServiceClient) SetMemberRole(ctx context.Context, req *connect.Request[v1.SetMemberRoleRequest]) (*connect.Response[v1.SetMemberRoleResponse], error) { + return c.setMemberRole.CallUnary(ctx, req) +} + +// RemoveMember calls auth.v1.AuthService.RemoveMember. +func (c *authServiceClient) RemoveMember(ctx context.Context, req *connect.Request[v1.RemoveMemberRequest]) (*connect.Response[v1.RemoveMemberResponse], error) { + return c.removeMember.CallUnary(ctx, req) +} + +// ListServiceAccounts calls auth.v1.AuthService.ListServiceAccounts. +func (c *authServiceClient) ListServiceAccounts(ctx context.Context, req *connect.Request[v1.ListServiceAccountsRequest]) (*connect.Response[v1.ListServiceAccountsResponse], error) { + return c.listServiceAccounts.CallUnary(ctx, req) +} + +// CreateServiceAccount calls auth.v1.AuthService.CreateServiceAccount. +func (c *authServiceClient) CreateServiceAccount(ctx context.Context, req *connect.Request[v1.CreateServiceAccountRequest]) (*connect.Response[v1.CreateServiceAccountResponse], error) { + return c.createServiceAccount.CallUnary(ctx, req) +} + +// RotateServiceAccountSecret calls auth.v1.AuthService.RotateServiceAccountSecret. +func (c *authServiceClient) RotateServiceAccountSecret(ctx context.Context, req *connect.Request[v1.RotateServiceAccountSecretRequest]) (*connect.Response[v1.RotateServiceAccountSecretResponse], error) { + return c.rotateServiceAccountSecret.CallUnary(ctx, req) +} + +// RemoveServiceAccount calls auth.v1.AuthService.RemoveServiceAccount. +func (c *authServiceClient) RemoveServiceAccount(ctx context.Context, req *connect.Request[v1.RemoveServiceAccountRequest]) (*connect.Response[v1.RemoveServiceAccountResponse], error) { + return c.removeServiceAccount.CallUnary(ctx, req) +} + +// AuthServiceHandler is an implementation of the auth.v1.AuthService service. +type AuthServiceHandler interface { + // Session; pre-token, public. An empty issuer means auth is disabled. + GetAuthConfig(context.Context, *connect.Request[v1.GetAuthConfigRequest]) (*connect.Response[v1.GetAuthConfigResponse], error) + Login(context.Context, *connect.Request[v1.LoginRequest]) (*connect.Response[v1.LoginResponse], error) + GetToken(context.Context, *connect.Request[v1.GetTokenRequest]) (*connect.Response[v1.GetTokenResponse], error) + Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) + Register(context.Context, *connect.Request[v1.RegisterRequest]) (*connect.Response[v1.RegisterResponse], error) + AcceptInvite(context.Context, *connect.Request[v1.AcceptInviteRequest]) (*connect.Response[v1.AcceptInviteResponse], error) + // Session; authenticated. The UI's sign-in check. + GetSession(context.Context, *connect.Request[v1.GetSessionRequest]) (*connect.Response[v1.GetSessionResponse], error) + // Members; authenticated tenant administration. + ListMembers(context.Context, *connect.Request[v1.ListMembersRequest]) (*connect.Response[v1.ListMembersResponse], error) + InviteMember(context.Context, *connect.Request[v1.InviteMemberRequest]) (*connect.Response[v1.InviteMemberResponse], error) + SetMemberRole(context.Context, *connect.Request[v1.SetMemberRoleRequest]) (*connect.Response[v1.SetMemberRoleResponse], error) + RemoveMember(context.Context, *connect.Request[v1.RemoveMemberRequest]) (*connect.Response[v1.RemoveMemberResponse], error) + // Service accounts; authenticated tenant administration. Secrets are only + // returned by create and rotate and cannot be retrieved later. + ListServiceAccounts(context.Context, *connect.Request[v1.ListServiceAccountsRequest]) (*connect.Response[v1.ListServiceAccountsResponse], error) + CreateServiceAccount(context.Context, *connect.Request[v1.CreateServiceAccountRequest]) (*connect.Response[v1.CreateServiceAccountResponse], error) + RotateServiceAccountSecret(context.Context, *connect.Request[v1.RotateServiceAccountSecretRequest]) (*connect.Response[v1.RotateServiceAccountSecretResponse], error) + RemoveServiceAccount(context.Context, *connect.Request[v1.RemoveServiceAccountRequest]) (*connect.Response[v1.RemoveServiceAccountResponse], error) +} + +// NewAuthServiceHandler builds an HTTP handler from the service implementation. It returns the path +// on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewAuthServiceHandler(svc AuthServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + authServiceMethods := v1.File_auth_v1_service_proto.Services().ByName("AuthService").Methods() + authServiceGetAuthConfigHandler := connect.NewUnaryHandler( + AuthServiceGetAuthConfigProcedure, + svc.GetAuthConfig, + connect.WithSchema(authServiceMethods.ByName("GetAuthConfig")), + connect.WithHandlerOptions(opts...), + ) + authServiceLoginHandler := connect.NewUnaryHandler( + AuthServiceLoginProcedure, + svc.Login, + connect.WithSchema(authServiceMethods.ByName("Login")), + connect.WithHandlerOptions(opts...), + ) + authServiceGetTokenHandler := connect.NewUnaryHandler( + AuthServiceGetTokenProcedure, + svc.GetToken, + connect.WithSchema(authServiceMethods.ByName("GetToken")), + connect.WithHandlerOptions(opts...), + ) + authServiceLogoutHandler := connect.NewUnaryHandler( + AuthServiceLogoutProcedure, + svc.Logout, + connect.WithSchema(authServiceMethods.ByName("Logout")), + connect.WithHandlerOptions(opts...), + ) + authServiceRegisterHandler := connect.NewUnaryHandler( + AuthServiceRegisterProcedure, + svc.Register, + connect.WithSchema(authServiceMethods.ByName("Register")), + connect.WithHandlerOptions(opts...), + ) + authServiceAcceptInviteHandler := connect.NewUnaryHandler( + AuthServiceAcceptInviteProcedure, + svc.AcceptInvite, + connect.WithSchema(authServiceMethods.ByName("AcceptInvite")), + connect.WithHandlerOptions(opts...), + ) + authServiceGetSessionHandler := connect.NewUnaryHandler( + AuthServiceGetSessionProcedure, + svc.GetSession, + connect.WithSchema(authServiceMethods.ByName("GetSession")), + connect.WithHandlerOptions(opts...), + ) + authServiceListMembersHandler := connect.NewUnaryHandler( + AuthServiceListMembersProcedure, + svc.ListMembers, + connect.WithSchema(authServiceMethods.ByName("ListMembers")), + connect.WithHandlerOptions(opts...), + ) + authServiceInviteMemberHandler := connect.NewUnaryHandler( + AuthServiceInviteMemberProcedure, + svc.InviteMember, + connect.WithSchema(authServiceMethods.ByName("InviteMember")), + connect.WithHandlerOptions(opts...), + ) + authServiceSetMemberRoleHandler := connect.NewUnaryHandler( + AuthServiceSetMemberRoleProcedure, + svc.SetMemberRole, + connect.WithSchema(authServiceMethods.ByName("SetMemberRole")), + connect.WithHandlerOptions(opts...), + ) + authServiceRemoveMemberHandler := connect.NewUnaryHandler( + AuthServiceRemoveMemberProcedure, + svc.RemoveMember, + connect.WithSchema(authServiceMethods.ByName("RemoveMember")), + connect.WithHandlerOptions(opts...), + ) + authServiceListServiceAccountsHandler := connect.NewUnaryHandler( + AuthServiceListServiceAccountsProcedure, + svc.ListServiceAccounts, + connect.WithSchema(authServiceMethods.ByName("ListServiceAccounts")), + connect.WithHandlerOptions(opts...), + ) + authServiceCreateServiceAccountHandler := connect.NewUnaryHandler( + AuthServiceCreateServiceAccountProcedure, + svc.CreateServiceAccount, + connect.WithSchema(authServiceMethods.ByName("CreateServiceAccount")), + connect.WithHandlerOptions(opts...), + ) + authServiceRotateServiceAccountSecretHandler := connect.NewUnaryHandler( + AuthServiceRotateServiceAccountSecretProcedure, + svc.RotateServiceAccountSecret, + connect.WithSchema(authServiceMethods.ByName("RotateServiceAccountSecret")), + connect.WithHandlerOptions(opts...), + ) + authServiceRemoveServiceAccountHandler := connect.NewUnaryHandler( + AuthServiceRemoveServiceAccountProcedure, + svc.RemoveServiceAccount, + connect.WithSchema(authServiceMethods.ByName("RemoveServiceAccount")), + connect.WithHandlerOptions(opts...), + ) + return "/auth.v1.AuthService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case AuthServiceGetAuthConfigProcedure: + authServiceGetAuthConfigHandler.ServeHTTP(w, r) + case AuthServiceLoginProcedure: + authServiceLoginHandler.ServeHTTP(w, r) + case AuthServiceGetTokenProcedure: + authServiceGetTokenHandler.ServeHTTP(w, r) + case AuthServiceLogoutProcedure: + authServiceLogoutHandler.ServeHTTP(w, r) + case AuthServiceRegisterProcedure: + authServiceRegisterHandler.ServeHTTP(w, r) + case AuthServiceAcceptInviteProcedure: + authServiceAcceptInviteHandler.ServeHTTP(w, r) + case AuthServiceGetSessionProcedure: + authServiceGetSessionHandler.ServeHTTP(w, r) + case AuthServiceListMembersProcedure: + authServiceListMembersHandler.ServeHTTP(w, r) + case AuthServiceInviteMemberProcedure: + authServiceInviteMemberHandler.ServeHTTP(w, r) + case AuthServiceSetMemberRoleProcedure: + authServiceSetMemberRoleHandler.ServeHTTP(w, r) + case AuthServiceRemoveMemberProcedure: + authServiceRemoveMemberHandler.ServeHTTP(w, r) + case AuthServiceListServiceAccountsProcedure: + authServiceListServiceAccountsHandler.ServeHTTP(w, r) + case AuthServiceCreateServiceAccountProcedure: + authServiceCreateServiceAccountHandler.ServeHTTP(w, r) + case AuthServiceRotateServiceAccountSecretProcedure: + authServiceRotateServiceAccountSecretHandler.ServeHTTP(w, r) + case AuthServiceRemoveServiceAccountProcedure: + authServiceRemoveServiceAccountHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedAuthServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedAuthServiceHandler struct{} + +func (UnimplementedAuthServiceHandler) GetAuthConfig(context.Context, *connect.Request[v1.GetAuthConfigRequest]) (*connect.Response[v1.GetAuthConfigResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.GetAuthConfig is not implemented")) +} + +func (UnimplementedAuthServiceHandler) Login(context.Context, *connect.Request[v1.LoginRequest]) (*connect.Response[v1.LoginResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.Login is not implemented")) +} + +func (UnimplementedAuthServiceHandler) GetToken(context.Context, *connect.Request[v1.GetTokenRequest]) (*connect.Response[v1.GetTokenResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.GetToken is not implemented")) +} + +func (UnimplementedAuthServiceHandler) Logout(context.Context, *connect.Request[v1.LogoutRequest]) (*connect.Response[v1.LogoutResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.Logout is not implemented")) +} + +func (UnimplementedAuthServiceHandler) Register(context.Context, *connect.Request[v1.RegisterRequest]) (*connect.Response[v1.RegisterResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.Register is not implemented")) +} + +func (UnimplementedAuthServiceHandler) AcceptInvite(context.Context, *connect.Request[v1.AcceptInviteRequest]) (*connect.Response[v1.AcceptInviteResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.AcceptInvite is not implemented")) +} + +func (UnimplementedAuthServiceHandler) GetSession(context.Context, *connect.Request[v1.GetSessionRequest]) (*connect.Response[v1.GetSessionResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.GetSession is not implemented")) +} + +func (UnimplementedAuthServiceHandler) ListMembers(context.Context, *connect.Request[v1.ListMembersRequest]) (*connect.Response[v1.ListMembersResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.ListMembers is not implemented")) +} + +func (UnimplementedAuthServiceHandler) InviteMember(context.Context, *connect.Request[v1.InviteMemberRequest]) (*connect.Response[v1.InviteMemberResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.InviteMember is not implemented")) +} + +func (UnimplementedAuthServiceHandler) SetMemberRole(context.Context, *connect.Request[v1.SetMemberRoleRequest]) (*connect.Response[v1.SetMemberRoleResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.SetMemberRole is not implemented")) +} + +func (UnimplementedAuthServiceHandler) RemoveMember(context.Context, *connect.Request[v1.RemoveMemberRequest]) (*connect.Response[v1.RemoveMemberResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.RemoveMember is not implemented")) +} + +func (UnimplementedAuthServiceHandler) ListServiceAccounts(context.Context, *connect.Request[v1.ListServiceAccountsRequest]) (*connect.Response[v1.ListServiceAccountsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.ListServiceAccounts is not implemented")) +} + +func (UnimplementedAuthServiceHandler) CreateServiceAccount(context.Context, *connect.Request[v1.CreateServiceAccountRequest]) (*connect.Response[v1.CreateServiceAccountResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.CreateServiceAccount is not implemented")) +} + +func (UnimplementedAuthServiceHandler) RotateServiceAccountSecret(context.Context, *connect.Request[v1.RotateServiceAccountSecretRequest]) (*connect.Response[v1.RotateServiceAccountSecretResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.RotateServiceAccountSecret is not implemented")) +} + +func (UnimplementedAuthServiceHandler) RemoveServiceAccount(context.Context, *connect.Request[v1.RemoveServiceAccountRequest]) (*connect.Response[v1.RemoveServiceAccountResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("auth.v1.AuthService.RemoveServiceAccount is not implemented")) +} diff --git a/identity/auth/v1/members.pb.go b/identity/auth/v1/members.pb.go new file mode 100644 index 0000000..e0e54fa --- /dev/null +++ b/identity/auth/v1/members.pb.go @@ -0,0 +1,628 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: auth/v1/members.proto + +package authv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Role is an app permission level. The vocabulary is the app's, not the +// provider's: an adapter maps whatever grants it stores onto these. +type Role int32 + +const ( + Role_ROLE_UNSPECIFIED Role = 0 + Role_ROLE_ADMIN Role = 1 + Role_ROLE_CREATOR Role = 2 + Role_ROLE_VIEWER Role = 3 +) + +// Enum value maps for Role. +var ( + Role_name = map[int32]string{ + 0: "ROLE_UNSPECIFIED", + 1: "ROLE_ADMIN", + 2: "ROLE_CREATOR", + 3: "ROLE_VIEWER", + } + Role_value = map[string]int32{ + "ROLE_UNSPECIFIED": 0, + "ROLE_ADMIN": 1, + "ROLE_CREATOR": 2, + "ROLE_VIEWER": 3, + } +) + +func (x Role) Enum() *Role { + p := new(Role) + *p = x + return p +} + +func (x Role) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Role) Descriptor() protoreflect.EnumDescriptor { + return file_auth_v1_members_proto_enumTypes[0].Descriptor() +} + +func (Role) Type() protoreflect.EnumType { + return &file_auth_v1_members_proto_enumTypes[0] +} + +func (x Role) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Role.Descriptor instead. +func (Role) EnumDescriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{0} +} + +// Member is one user of a tenant. +type Member struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` + Role Role `protobuf:"varint,4,opt,name=role,proto3,enum=auth.v1.Role" json:"role,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Member) Reset() { + *x = Member{} + mi := &file_auth_v1_members_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Member) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Member) ProtoMessage() {} + +func (x *Member) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Member.ProtoReflect.Descriptor instead. +func (*Member) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{0} +} + +func (x *Member) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *Member) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Member) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *Member) GetRole() Role { + if x != nil { + return x.Role + } + return Role_ROLE_UNSPECIFIED +} + +type ListMembersRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListMembersRequest) Reset() { + *x = ListMembersRequest{} + mi := &file_auth_v1_members_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListMembersRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListMembersRequest) ProtoMessage() {} + +func (x *ListMembersRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListMembersRequest.ProtoReflect.Descriptor instead. +func (*ListMembersRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{1} +} + +type ListMembersResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Members []*Member `protobuf:"bytes,1,rep,name=members,proto3" json:"members,omitempty"` + // can_manage reports whether the caller may invite, re-role, or remove. + // The UI renders read-only when false. + CanManage bool `protobuf:"varint,2,opt,name=can_manage,json=canManage,proto3" json:"can_manage,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListMembersResponse) Reset() { + *x = ListMembersResponse{} + mi := &file_auth_v1_members_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListMembersResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListMembersResponse) ProtoMessage() {} + +func (x *ListMembersResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListMembersResponse.ProtoReflect.Descriptor instead. +func (*ListMembersResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{2} +} + +func (x *ListMembersResponse) GetMembers() []*Member { + if x != nil { + return x.Members + } + return nil +} + +func (x *ListMembersResponse) GetCanManage() bool { + if x != nil { + return x.CanManage + } + return false +} + +type InviteMemberRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + GivenName string `protobuf:"bytes,2,opt,name=given_name,json=givenName,proto3" json:"given_name,omitempty"` + FamilyName string `protobuf:"bytes,3,opt,name=family_name,json=familyName,proto3" json:"family_name,omitempty"` + Role Role `protobuf:"varint,4,opt,name=role,proto3,enum=auth.v1.Role" json:"role,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InviteMemberRequest) Reset() { + *x = InviteMemberRequest{} + mi := &file_auth_v1_members_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InviteMemberRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InviteMemberRequest) ProtoMessage() {} + +func (x *InviteMemberRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InviteMemberRequest.ProtoReflect.Descriptor instead. +func (*InviteMemberRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{3} +} + +func (x *InviteMemberRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *InviteMemberRequest) GetGivenName() string { + if x != nil { + return x.GivenName + } + return "" +} + +func (x *InviteMemberRequest) GetFamilyName() string { + if x != nil { + return x.FamilyName + } + return "" +} + +func (x *InviteMemberRequest) GetRole() Role { + if x != nil { + return x.Role + } + return Role_ROLE_UNSPECIFIED +} + +// InviteMemberResponse carries the invitation an admin shares out of band. +// Nothing is emailed. +type InviteMemberResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Code string `protobuf:"bytes,2,opt,name=code,proto3" json:"code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InviteMemberResponse) Reset() { + *x = InviteMemberResponse{} + mi := &file_auth_v1_members_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InviteMemberResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InviteMemberResponse) ProtoMessage() {} + +func (x *InviteMemberResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InviteMemberResponse.ProtoReflect.Descriptor instead. +func (*InviteMemberResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{4} +} + +func (x *InviteMemberResponse) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *InviteMemberResponse) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +type SetMemberRoleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Role Role `protobuf:"varint,2,opt,name=role,proto3,enum=auth.v1.Role" json:"role,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetMemberRoleRequest) Reset() { + *x = SetMemberRoleRequest{} + mi := &file_auth_v1_members_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetMemberRoleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetMemberRoleRequest) ProtoMessage() {} + +func (x *SetMemberRoleRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetMemberRoleRequest.ProtoReflect.Descriptor instead. +func (*SetMemberRoleRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{5} +} + +func (x *SetMemberRoleRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *SetMemberRoleRequest) GetRole() Role { + if x != nil { + return x.Role + } + return Role_ROLE_UNSPECIFIED +} + +type SetMemberRoleResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetMemberRoleResponse) Reset() { + *x = SetMemberRoleResponse{} + mi := &file_auth_v1_members_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetMemberRoleResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetMemberRoleResponse) ProtoMessage() {} + +func (x *SetMemberRoleResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetMemberRoleResponse.ProtoReflect.Descriptor instead. +func (*SetMemberRoleResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{6} +} + +type RemoveMemberRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveMemberRequest) Reset() { + *x = RemoveMemberRequest{} + mi := &file_auth_v1_members_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveMemberRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveMemberRequest) ProtoMessage() {} + +func (x *RemoveMemberRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveMemberRequest.ProtoReflect.Descriptor instead. +func (*RemoveMemberRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{7} +} + +func (x *RemoveMemberRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +type RemoveMemberResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveMemberResponse) Reset() { + *x = RemoveMemberResponse{} + mi := &file_auth_v1_members_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveMemberResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveMemberResponse) ProtoMessage() {} + +func (x *RemoveMemberResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_members_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveMemberResponse.ProtoReflect.Descriptor instead. +func (*RemoveMemberResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_members_proto_rawDescGZIP(), []int{8} +} + +var File_auth_v1_members_proto protoreflect.FileDescriptor + +const file_auth_v1_members_proto_rawDesc = "" + + "\n" + + "\x15auth/v1/members.proto\x12\aauth.v1\"n\n" + + "\x06Member\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" + + "\x05email\x18\x03 \x01(\tR\x05email\x12!\n" + + "\x04role\x18\x04 \x01(\x0e2\r.auth.v1.RoleR\x04role\"\x14\n" + + "\x12ListMembersRequest\"_\n" + + "\x13ListMembersResponse\x12)\n" + + "\amembers\x18\x01 \x03(\v2\x0f.auth.v1.MemberR\amembers\x12\x1d\n" + + "\n" + + "can_manage\x18\x02 \x01(\bR\tcanManage\"\x8e\x01\n" + + "\x13InviteMemberRequest\x12\x14\n" + + "\x05email\x18\x01 \x01(\tR\x05email\x12\x1d\n" + + "\n" + + "given_name\x18\x02 \x01(\tR\tgivenName\x12\x1f\n" + + "\vfamily_name\x18\x03 \x01(\tR\n" + + "familyName\x12!\n" + + "\x04role\x18\x04 \x01(\x0e2\r.auth.v1.RoleR\x04role\"C\n" + + "\x14InviteMemberResponse\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" + + "\x04code\x18\x02 \x01(\tR\x04code\"R\n" + + "\x14SetMemberRoleRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12!\n" + + "\x04role\x18\x02 \x01(\x0e2\r.auth.v1.RoleR\x04role\"\x17\n" + + "\x15SetMemberRoleResponse\".\n" + + "\x13RemoveMemberRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\"\x16\n" + + "\x14RemoveMemberResponse*O\n" + + "\x04Role\x12\x14\n" + + "\x10ROLE_UNSPECIFIED\x10\x00\x12\x0e\n" + + "\n" + + "ROLE_ADMIN\x10\x01\x12\x10\n" + + "\fROLE_CREATOR\x10\x02\x12\x0f\n" + + "\vROLE_VIEWER\x10\x03B\x8e\x01\n" + + "\vcom.auth.v1B\fMembersProtoP\x01Z4github.com/galaxy-io/modules/identity/auth/v1;authv1\xa2\x02\x03AXX\xaa\x02\aAuth.V1\xca\x02\aAuth\\V1\xe2\x02\x13Auth\\V1\\GPBMetadata\xea\x02\bAuth::V1b\x06proto3" + +var ( + file_auth_v1_members_proto_rawDescOnce sync.Once + file_auth_v1_members_proto_rawDescData []byte +) + +func file_auth_v1_members_proto_rawDescGZIP() []byte { + file_auth_v1_members_proto_rawDescOnce.Do(func() { + file_auth_v1_members_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_auth_v1_members_proto_rawDesc), len(file_auth_v1_members_proto_rawDesc))) + }) + return file_auth_v1_members_proto_rawDescData +} + +var file_auth_v1_members_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_auth_v1_members_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_auth_v1_members_proto_goTypes = []any{ + (Role)(0), // 0: auth.v1.Role + (*Member)(nil), // 1: auth.v1.Member + (*ListMembersRequest)(nil), // 2: auth.v1.ListMembersRequest + (*ListMembersResponse)(nil), // 3: auth.v1.ListMembersResponse + (*InviteMemberRequest)(nil), // 4: auth.v1.InviteMemberRequest + (*InviteMemberResponse)(nil), // 5: auth.v1.InviteMemberResponse + (*SetMemberRoleRequest)(nil), // 6: auth.v1.SetMemberRoleRequest + (*SetMemberRoleResponse)(nil), // 7: auth.v1.SetMemberRoleResponse + (*RemoveMemberRequest)(nil), // 8: auth.v1.RemoveMemberRequest + (*RemoveMemberResponse)(nil), // 9: auth.v1.RemoveMemberResponse +} +var file_auth_v1_members_proto_depIdxs = []int32{ + 0, // 0: auth.v1.Member.role:type_name -> auth.v1.Role + 1, // 1: auth.v1.ListMembersResponse.members:type_name -> auth.v1.Member + 0, // 2: auth.v1.InviteMemberRequest.role:type_name -> auth.v1.Role + 0, // 3: auth.v1.SetMemberRoleRequest.role:type_name -> auth.v1.Role + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_auth_v1_members_proto_init() } +func file_auth_v1_members_proto_init() { + if File_auth_v1_members_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_auth_v1_members_proto_rawDesc), len(file_auth_v1_members_proto_rawDesc)), + NumEnums: 1, + NumMessages: 9, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_auth_v1_members_proto_goTypes, + DependencyIndexes: file_auth_v1_members_proto_depIdxs, + EnumInfos: file_auth_v1_members_proto_enumTypes, + MessageInfos: file_auth_v1_members_proto_msgTypes, + }.Build() + File_auth_v1_members_proto = out.File + file_auth_v1_members_proto_goTypes = nil + file_auth_v1_members_proto_depIdxs = nil +} diff --git a/identity/auth/v1/service.pb.go b/identity/auth/v1/service.pb.go new file mode 100644 index 0000000..437b40a --- /dev/null +++ b/identity/auth/v1/service.pb.go @@ -0,0 +1,141 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: auth/v1/service.proto + +package authv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_auth_v1_service_proto protoreflect.FileDescriptor + +const file_auth_v1_service_proto_rawDesc = "" + + "\n" + + "\x15auth/v1/service.proto\x12\aauth.v1\x1a\x15auth/v1/members.proto\x1a\x1eauth/v1/service_accounts.proto\x1a\x15auth/v1/session.proto2\xbd\t\n" + + "\vAuthService\x12N\n" + + "\rGetAuthConfig\x12\x1d.auth.v1.GetAuthConfigRequest\x1a\x1e.auth.v1.GetAuthConfigResponse\x126\n" + + "\x05Login\x12\x15.auth.v1.LoginRequest\x1a\x16.auth.v1.LoginResponse\x12?\n" + + "\bGetToken\x12\x18.auth.v1.GetTokenRequest\x1a\x19.auth.v1.GetTokenResponse\x129\n" + + "\x06Logout\x12\x16.auth.v1.LogoutRequest\x1a\x17.auth.v1.LogoutResponse\x12?\n" + + "\bRegister\x12\x18.auth.v1.RegisterRequest\x1a\x19.auth.v1.RegisterResponse\x12K\n" + + "\fAcceptInvite\x12\x1c.auth.v1.AcceptInviteRequest\x1a\x1d.auth.v1.AcceptInviteResponse\x12E\n" + + "\n" + + "GetSession\x12\x1a.auth.v1.GetSessionRequest\x1a\x1b.auth.v1.GetSessionResponse\x12H\n" + + "\vListMembers\x12\x1b.auth.v1.ListMembersRequest\x1a\x1c.auth.v1.ListMembersResponse\x12K\n" + + "\fInviteMember\x12\x1c.auth.v1.InviteMemberRequest\x1a\x1d.auth.v1.InviteMemberResponse\x12N\n" + + "\rSetMemberRole\x12\x1d.auth.v1.SetMemberRoleRequest\x1a\x1e.auth.v1.SetMemberRoleResponse\x12K\n" + + "\fRemoveMember\x12\x1c.auth.v1.RemoveMemberRequest\x1a\x1d.auth.v1.RemoveMemberResponse\x12`\n" + + "\x13ListServiceAccounts\x12#.auth.v1.ListServiceAccountsRequest\x1a$.auth.v1.ListServiceAccountsResponse\x12c\n" + + "\x14CreateServiceAccount\x12$.auth.v1.CreateServiceAccountRequest\x1a%.auth.v1.CreateServiceAccountResponse\x12u\n" + + "\x1aRotateServiceAccountSecret\x12*.auth.v1.RotateServiceAccountSecretRequest\x1a+.auth.v1.RotateServiceAccountSecretResponse\x12c\n" + + "\x14RemoveServiceAccount\x12$.auth.v1.RemoveServiceAccountRequest\x1a%.auth.v1.RemoveServiceAccountResponseB\x8e\x01\n" + + "\vcom.auth.v1B\fServiceProtoP\x01Z4github.com/galaxy-io/modules/identity/auth/v1;authv1\xa2\x02\x03AXX\xaa\x02\aAuth.V1\xca\x02\aAuth\\V1\xe2\x02\x13Auth\\V1\\GPBMetadata\xea\x02\bAuth::V1b\x06proto3" + +var file_auth_v1_service_proto_goTypes = []any{ + (*GetAuthConfigRequest)(nil), // 0: auth.v1.GetAuthConfigRequest + (*LoginRequest)(nil), // 1: auth.v1.LoginRequest + (*GetTokenRequest)(nil), // 2: auth.v1.GetTokenRequest + (*LogoutRequest)(nil), // 3: auth.v1.LogoutRequest + (*RegisterRequest)(nil), // 4: auth.v1.RegisterRequest + (*AcceptInviteRequest)(nil), // 5: auth.v1.AcceptInviteRequest + (*GetSessionRequest)(nil), // 6: auth.v1.GetSessionRequest + (*ListMembersRequest)(nil), // 7: auth.v1.ListMembersRequest + (*InviteMemberRequest)(nil), // 8: auth.v1.InviteMemberRequest + (*SetMemberRoleRequest)(nil), // 9: auth.v1.SetMemberRoleRequest + (*RemoveMemberRequest)(nil), // 10: auth.v1.RemoveMemberRequest + (*ListServiceAccountsRequest)(nil), // 11: auth.v1.ListServiceAccountsRequest + (*CreateServiceAccountRequest)(nil), // 12: auth.v1.CreateServiceAccountRequest + (*RotateServiceAccountSecretRequest)(nil), // 13: auth.v1.RotateServiceAccountSecretRequest + (*RemoveServiceAccountRequest)(nil), // 14: auth.v1.RemoveServiceAccountRequest + (*GetAuthConfigResponse)(nil), // 15: auth.v1.GetAuthConfigResponse + (*LoginResponse)(nil), // 16: auth.v1.LoginResponse + (*GetTokenResponse)(nil), // 17: auth.v1.GetTokenResponse + (*LogoutResponse)(nil), // 18: auth.v1.LogoutResponse + (*RegisterResponse)(nil), // 19: auth.v1.RegisterResponse + (*AcceptInviteResponse)(nil), // 20: auth.v1.AcceptInviteResponse + (*GetSessionResponse)(nil), // 21: auth.v1.GetSessionResponse + (*ListMembersResponse)(nil), // 22: auth.v1.ListMembersResponse + (*InviteMemberResponse)(nil), // 23: auth.v1.InviteMemberResponse + (*SetMemberRoleResponse)(nil), // 24: auth.v1.SetMemberRoleResponse + (*RemoveMemberResponse)(nil), // 25: auth.v1.RemoveMemberResponse + (*ListServiceAccountsResponse)(nil), // 26: auth.v1.ListServiceAccountsResponse + (*CreateServiceAccountResponse)(nil), // 27: auth.v1.CreateServiceAccountResponse + (*RotateServiceAccountSecretResponse)(nil), // 28: auth.v1.RotateServiceAccountSecretResponse + (*RemoveServiceAccountResponse)(nil), // 29: auth.v1.RemoveServiceAccountResponse +} +var file_auth_v1_service_proto_depIdxs = []int32{ + 0, // 0: auth.v1.AuthService.GetAuthConfig:input_type -> auth.v1.GetAuthConfigRequest + 1, // 1: auth.v1.AuthService.Login:input_type -> auth.v1.LoginRequest + 2, // 2: auth.v1.AuthService.GetToken:input_type -> auth.v1.GetTokenRequest + 3, // 3: auth.v1.AuthService.Logout:input_type -> auth.v1.LogoutRequest + 4, // 4: auth.v1.AuthService.Register:input_type -> auth.v1.RegisterRequest + 5, // 5: auth.v1.AuthService.AcceptInvite:input_type -> auth.v1.AcceptInviteRequest + 6, // 6: auth.v1.AuthService.GetSession:input_type -> auth.v1.GetSessionRequest + 7, // 7: auth.v1.AuthService.ListMembers:input_type -> auth.v1.ListMembersRequest + 8, // 8: auth.v1.AuthService.InviteMember:input_type -> auth.v1.InviteMemberRequest + 9, // 9: auth.v1.AuthService.SetMemberRole:input_type -> auth.v1.SetMemberRoleRequest + 10, // 10: auth.v1.AuthService.RemoveMember:input_type -> auth.v1.RemoveMemberRequest + 11, // 11: auth.v1.AuthService.ListServiceAccounts:input_type -> auth.v1.ListServiceAccountsRequest + 12, // 12: auth.v1.AuthService.CreateServiceAccount:input_type -> auth.v1.CreateServiceAccountRequest + 13, // 13: auth.v1.AuthService.RotateServiceAccountSecret:input_type -> auth.v1.RotateServiceAccountSecretRequest + 14, // 14: auth.v1.AuthService.RemoveServiceAccount:input_type -> auth.v1.RemoveServiceAccountRequest + 15, // 15: auth.v1.AuthService.GetAuthConfig:output_type -> auth.v1.GetAuthConfigResponse + 16, // 16: auth.v1.AuthService.Login:output_type -> auth.v1.LoginResponse + 17, // 17: auth.v1.AuthService.GetToken:output_type -> auth.v1.GetTokenResponse + 18, // 18: auth.v1.AuthService.Logout:output_type -> auth.v1.LogoutResponse + 19, // 19: auth.v1.AuthService.Register:output_type -> auth.v1.RegisterResponse + 20, // 20: auth.v1.AuthService.AcceptInvite:output_type -> auth.v1.AcceptInviteResponse + 21, // 21: auth.v1.AuthService.GetSession:output_type -> auth.v1.GetSessionResponse + 22, // 22: auth.v1.AuthService.ListMembers:output_type -> auth.v1.ListMembersResponse + 23, // 23: auth.v1.AuthService.InviteMember:output_type -> auth.v1.InviteMemberResponse + 24, // 24: auth.v1.AuthService.SetMemberRole:output_type -> auth.v1.SetMemberRoleResponse + 25, // 25: auth.v1.AuthService.RemoveMember:output_type -> auth.v1.RemoveMemberResponse + 26, // 26: auth.v1.AuthService.ListServiceAccounts:output_type -> auth.v1.ListServiceAccountsResponse + 27, // 27: auth.v1.AuthService.CreateServiceAccount:output_type -> auth.v1.CreateServiceAccountResponse + 28, // 28: auth.v1.AuthService.RotateServiceAccountSecret:output_type -> auth.v1.RotateServiceAccountSecretResponse + 29, // 29: auth.v1.AuthService.RemoveServiceAccount:output_type -> auth.v1.RemoveServiceAccountResponse + 15, // [15:30] is the sub-list for method output_type + 0, // [0:15] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_auth_v1_service_proto_init() } +func file_auth_v1_service_proto_init() { + if File_auth_v1_service_proto != nil { + return + } + file_auth_v1_members_proto_init() + file_auth_v1_service_accounts_proto_init() + file_auth_v1_session_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_auth_v1_service_proto_rawDesc), len(file_auth_v1_service_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_auth_v1_service_proto_goTypes, + DependencyIndexes: file_auth_v1_service_proto_depIdxs, + }.Build() + File_auth_v1_service_proto = out.File + file_auth_v1_service_proto_goTypes = nil + file_auth_v1_service_proto_depIdxs = nil +} diff --git a/identity/auth/v1/service_accounts.pb.go b/identity/auth/v1/service_accounts.pb.go new file mode 100644 index 0000000..6c85fbd --- /dev/null +++ b/identity/auth/v1/service_accounts.pb.go @@ -0,0 +1,575 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: auth/v1/service_accounts.proto + +package authv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// ServiceAccount is a non-human identity used by automation such as the +// app's CLI. Its client secret is never returned by list operations. +type ServiceAccount struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + ClientId string `protobuf:"bytes,2,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` + Role Role `protobuf:"varint,5,opt,name=role,proto3,enum=auth.v1.Role" json:"role,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServiceAccount) Reset() { + *x = ServiceAccount{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServiceAccount) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServiceAccount) ProtoMessage() {} + +func (x *ServiceAccount) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServiceAccount.ProtoReflect.Descriptor instead. +func (*ServiceAccount) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{0} +} + +func (x *ServiceAccount) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ServiceAccount) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *ServiceAccount) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ServiceAccount) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *ServiceAccount) GetRole() Role { + if x != nil { + return x.Role + } + return Role_ROLE_UNSPECIFIED +} + +type ListServiceAccountsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListServiceAccountsRequest) Reset() { + *x = ListServiceAccountsRequest{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListServiceAccountsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListServiceAccountsRequest) ProtoMessage() {} + +func (x *ListServiceAccountsRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListServiceAccountsRequest.ProtoReflect.Descriptor instead. +func (*ListServiceAccountsRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{1} +} + +type ListServiceAccountsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServiceAccounts []*ServiceAccount `protobuf:"bytes,1,rep,name=service_accounts,json=serviceAccounts,proto3" json:"service_accounts,omitempty"` + CanManage bool `protobuf:"varint,2,opt,name=can_manage,json=canManage,proto3" json:"can_manage,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListServiceAccountsResponse) Reset() { + *x = ListServiceAccountsResponse{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListServiceAccountsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListServiceAccountsResponse) ProtoMessage() {} + +func (x *ListServiceAccountsResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListServiceAccountsResponse.ProtoReflect.Descriptor instead. +func (*ListServiceAccountsResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{2} +} + +func (x *ListServiceAccountsResponse) GetServiceAccounts() []*ServiceAccount { + if x != nil { + return x.ServiceAccounts + } + return nil +} + +func (x *ListServiceAccountsResponse) GetCanManage() bool { + if x != nil { + return x.CanManage + } + return false +} + +type CreateServiceAccountRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + Role Role `protobuf:"varint,3,opt,name=role,proto3,enum=auth.v1.Role" json:"role,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateServiceAccountRequest) Reset() { + *x = CreateServiceAccountRequest{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateServiceAccountRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateServiceAccountRequest) ProtoMessage() {} + +func (x *CreateServiceAccountRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateServiceAccountRequest.ProtoReflect.Descriptor instead. +func (*CreateServiceAccountRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{3} +} + +func (x *CreateServiceAccountRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateServiceAccountRequest) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *CreateServiceAccountRequest) GetRole() Role { + if x != nil { + return x.Role + } + return Role_ROLE_UNSPECIFIED +} + +// CreateServiceAccountResponse returns client_secret exactly once. The app +// does not persist it and Zitadel cannot retrieve it again. +type CreateServiceAccountResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ServiceAccount *ServiceAccount `protobuf:"bytes,1,opt,name=service_account,json=serviceAccount,proto3" json:"service_account,omitempty"` + ClientSecret string `protobuf:"bytes,2,opt,name=client_secret,json=clientSecret,proto3" json:"client_secret,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateServiceAccountResponse) Reset() { + *x = CreateServiceAccountResponse{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateServiceAccountResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateServiceAccountResponse) ProtoMessage() {} + +func (x *CreateServiceAccountResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateServiceAccountResponse.ProtoReflect.Descriptor instead. +func (*CreateServiceAccountResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{4} +} + +func (x *CreateServiceAccountResponse) GetServiceAccount() *ServiceAccount { + if x != nil { + return x.ServiceAccount + } + return nil +} + +func (x *CreateServiceAccountResponse) GetClientSecret() string { + if x != nil { + return x.ClientSecret + } + return "" +} + +type RotateServiceAccountSecretRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RotateServiceAccountSecretRequest) Reset() { + *x = RotateServiceAccountSecretRequest{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RotateServiceAccountSecretRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RotateServiceAccountSecretRequest) ProtoMessage() {} + +func (x *RotateServiceAccountSecretRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RotateServiceAccountSecretRequest.ProtoReflect.Descriptor instead. +func (*RotateServiceAccountSecretRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{5} +} + +func (x *RotateServiceAccountSecretRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +// RotateServiceAccountSecretResponse returns the replacement secret exactly +// once. The previous secret is invalid after rotation. +type RotateServiceAccountSecretResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + ClientSecret string `protobuf:"bytes,2,opt,name=client_secret,json=clientSecret,proto3" json:"client_secret,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RotateServiceAccountSecretResponse) Reset() { + *x = RotateServiceAccountSecretResponse{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RotateServiceAccountSecretResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RotateServiceAccountSecretResponse) ProtoMessage() {} + +func (x *RotateServiceAccountSecretResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RotateServiceAccountSecretResponse.ProtoReflect.Descriptor instead. +func (*RotateServiceAccountSecretResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{6} +} + +func (x *RotateServiceAccountSecretResponse) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *RotateServiceAccountSecretResponse) GetClientSecret() string { + if x != nil { + return x.ClientSecret + } + return "" +} + +type RemoveServiceAccountRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveServiceAccountRequest) Reset() { + *x = RemoveServiceAccountRequest{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveServiceAccountRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveServiceAccountRequest) ProtoMessage() {} + +func (x *RemoveServiceAccountRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveServiceAccountRequest.ProtoReflect.Descriptor instead. +func (*RemoveServiceAccountRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{7} +} + +func (x *RemoveServiceAccountRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +type RemoveServiceAccountResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveServiceAccountResponse) Reset() { + *x = RemoveServiceAccountResponse{} + mi := &file_auth_v1_service_accounts_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveServiceAccountResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveServiceAccountResponse) ProtoMessage() {} + +func (x *RemoveServiceAccountResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_service_accounts_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveServiceAccountResponse.ProtoReflect.Descriptor instead. +func (*RemoveServiceAccountResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_service_accounts_proto_rawDescGZIP(), []int{8} +} + +var File_auth_v1_service_accounts_proto protoreflect.FileDescriptor + +const file_auth_v1_service_accounts_proto_rawDesc = "" + + "\n" + + "\x1eauth/v1/service_accounts.proto\x12\aauth.v1\x1a\x15auth/v1/members.proto\"\x9f\x01\n" + + "\x0eServiceAccount\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x1b\n" + + "\tclient_id\x18\x02 \x01(\tR\bclientId\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x04 \x01(\tR\vdescription\x12!\n" + + "\x04role\x18\x05 \x01(\x0e2\r.auth.v1.RoleR\x04role\"\x1c\n" + + "\x1aListServiceAccountsRequest\"\x80\x01\n" + + "\x1bListServiceAccountsResponse\x12B\n" + + "\x10service_accounts\x18\x01 \x03(\v2\x17.auth.v1.ServiceAccountR\x0fserviceAccounts\x12\x1d\n" + + "\n" + + "can_manage\x18\x02 \x01(\bR\tcanManage\"v\n" + + "\x1bCreateServiceAccountRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\x12!\n" + + "\x04role\x18\x03 \x01(\x0e2\r.auth.v1.RoleR\x04role\"\x85\x01\n" + + "\x1cCreateServiceAccountResponse\x12@\n" + + "\x0fservice_account\x18\x01 \x01(\v2\x17.auth.v1.ServiceAccountR\x0eserviceAccount\x12#\n" + + "\rclient_secret\x18\x02 \x01(\tR\fclientSecret\"<\n" + + "!RotateServiceAccountSecretRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\"f\n" + + "\"RotateServiceAccountSecretResponse\x12\x1b\n" + + "\tclient_id\x18\x01 \x01(\tR\bclientId\x12#\n" + + "\rclient_secret\x18\x02 \x01(\tR\fclientSecret\"6\n" + + "\x1bRemoveServiceAccountRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\"\x1e\n" + + "\x1cRemoveServiceAccountResponseB\x96\x01\n" + + "\vcom.auth.v1B\x14ServiceAccountsProtoP\x01Z4github.com/galaxy-io/modules/identity/auth/v1;authv1\xa2\x02\x03AXX\xaa\x02\aAuth.V1\xca\x02\aAuth\\V1\xe2\x02\x13Auth\\V1\\GPBMetadata\xea\x02\bAuth::V1b\x06proto3" + +var ( + file_auth_v1_service_accounts_proto_rawDescOnce sync.Once + file_auth_v1_service_accounts_proto_rawDescData []byte +) + +func file_auth_v1_service_accounts_proto_rawDescGZIP() []byte { + file_auth_v1_service_accounts_proto_rawDescOnce.Do(func() { + file_auth_v1_service_accounts_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_auth_v1_service_accounts_proto_rawDesc), len(file_auth_v1_service_accounts_proto_rawDesc))) + }) + return file_auth_v1_service_accounts_proto_rawDescData +} + +var file_auth_v1_service_accounts_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_auth_v1_service_accounts_proto_goTypes = []any{ + (*ServiceAccount)(nil), // 0: auth.v1.ServiceAccount + (*ListServiceAccountsRequest)(nil), // 1: auth.v1.ListServiceAccountsRequest + (*ListServiceAccountsResponse)(nil), // 2: auth.v1.ListServiceAccountsResponse + (*CreateServiceAccountRequest)(nil), // 3: auth.v1.CreateServiceAccountRequest + (*CreateServiceAccountResponse)(nil), // 4: auth.v1.CreateServiceAccountResponse + (*RotateServiceAccountSecretRequest)(nil), // 5: auth.v1.RotateServiceAccountSecretRequest + (*RotateServiceAccountSecretResponse)(nil), // 6: auth.v1.RotateServiceAccountSecretResponse + (*RemoveServiceAccountRequest)(nil), // 7: auth.v1.RemoveServiceAccountRequest + (*RemoveServiceAccountResponse)(nil), // 8: auth.v1.RemoveServiceAccountResponse + (Role)(0), // 9: auth.v1.Role +} +var file_auth_v1_service_accounts_proto_depIdxs = []int32{ + 9, // 0: auth.v1.ServiceAccount.role:type_name -> auth.v1.Role + 0, // 1: auth.v1.ListServiceAccountsResponse.service_accounts:type_name -> auth.v1.ServiceAccount + 9, // 2: auth.v1.CreateServiceAccountRequest.role:type_name -> auth.v1.Role + 0, // 3: auth.v1.CreateServiceAccountResponse.service_account:type_name -> auth.v1.ServiceAccount + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_auth_v1_service_accounts_proto_init() } +func file_auth_v1_service_accounts_proto_init() { + if File_auth_v1_service_accounts_proto != nil { + return + } + file_auth_v1_members_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_auth_v1_service_accounts_proto_rawDesc), len(file_auth_v1_service_accounts_proto_rawDesc)), + NumEnums: 0, + NumMessages: 9, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_auth_v1_service_accounts_proto_goTypes, + DependencyIndexes: file_auth_v1_service_accounts_proto_depIdxs, + MessageInfos: file_auth_v1_service_accounts_proto_msgTypes, + }.Build() + File_auth_v1_service_accounts_proto = out.File + file_auth_v1_service_accounts_proto_goTypes = nil + file_auth_v1_service_accounts_proto_depIdxs = nil +} diff --git a/identity/auth/v1/session.pb.go b/identity/auth/v1/session.pb.go new file mode 100644 index 0000000..3463c8a --- /dev/null +++ b/identity/auth/v1/session.pb.go @@ -0,0 +1,834 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: auth/v1/session.proto + +package authv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetAuthConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAuthConfigRequest) Reset() { + *x = GetAuthConfigRequest{} + mi := &file_auth_v1_session_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAuthConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAuthConfigRequest) ProtoMessage() {} + +func (x *GetAuthConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAuthConfigRequest.ProtoReflect.Descriptor instead. +func (*GetAuthConfigRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{0} +} + +// GetAuthConfigResponse tells clients how to authenticate. An empty issuer +// means no provider is configured: the UI renders without a session and every +// other AuthService RPC is unimplemented. +type GetAuthConfigResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Issuer string `protobuf:"bytes,1,opt,name=issuer,proto3" json:"issuer,omitempty"` + // service_account_scopes are requested by non-interactive clients. The + // provider owns these values so clients do not need provider-specific + // project IDs or reserved scope knowledge. + ServiceAccountScopes []string `protobuf:"bytes,2,rep,name=service_account_scopes,json=serviceAccountScopes,proto3" json:"service_account_scopes,omitempty"` + // invite_only means sign-up is closed: Register is refused and the UI + // offers no way to create an organization. People join by invitation. + InviteOnly bool `protobuf:"varint,3,opt,name=invite_only,json=inviteOnly,proto3" json:"invite_only,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAuthConfigResponse) Reset() { + *x = GetAuthConfigResponse{} + mi := &file_auth_v1_session_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAuthConfigResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAuthConfigResponse) ProtoMessage() {} + +func (x *GetAuthConfigResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAuthConfigResponse.ProtoReflect.Descriptor instead. +func (*GetAuthConfigResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{1} +} + +func (x *GetAuthConfigResponse) GetIssuer() string { + if x != nil { + return x.Issuer + } + return "" +} + +func (x *GetAuthConfigResponse) GetServiceAccountScopes() []string { + if x != nil { + return x.ServiceAccountScopes + } + return nil +} + +func (x *GetAuthConfigResponse) GetInviteOnly() bool { + if x != nil { + return x.InviteOnly + } + return false +} + +// LoginRequest carries the credentials the app's own login page collected. +// A successful login answers with an HttpOnly session cookie that every later +// RPC carries; nothing about the provider reaches the browser. +type LoginRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + LoginName string `protobuf:"bytes,1,opt,name=login_name,json=loginName,proto3" json:"login_name,omitempty"` + Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LoginRequest) Reset() { + *x = LoginRequest{} + mi := &file_auth_v1_session_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LoginRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LoginRequest) ProtoMessage() {} + +func (x *LoginRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoginRequest.ProtoReflect.Descriptor instead. +func (*LoginRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{2} +} + +func (x *LoginRequest) GetLoginName() string { + if x != nil { + return x.LoginName + } + return "" +} + +func (x *LoginRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +type LoginResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LoginResponse) Reset() { + *x = LoginResponse{} + mi := &file_auth_v1_session_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LoginResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LoginResponse) ProtoMessage() {} + +func (x *LoginResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LoginResponse.ProtoReflect.Descriptor instead. +func (*LoginResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{3} +} + +// GetTokenRequest exchanges a service account's credentials for an access +// token. The server runs the grant against the provider, so SDKs and the +// CLI need no provider knowledge and no route to it. +type GetTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"` + ClientSecret string `protobuf:"bytes,2,opt,name=client_secret,json=clientSecret,proto3" json:"client_secret,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTokenRequest) Reset() { + *x = GetTokenRequest{} + mi := &file_auth_v1_session_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTokenRequest) ProtoMessage() {} + +func (x *GetTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTokenRequest.ProtoReflect.Descriptor instead. +func (*GetTokenRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{4} +} + +func (x *GetTokenRequest) GetClientId() string { + if x != nil { + return x.ClientId + } + return "" +} + +func (x *GetTokenRequest) GetClientSecret() string { + if x != nil { + return x.ClientSecret + } + return "" +} + +// GetTokenResponse carries a bearer token and its lifetime in seconds. +// Clients mint again as it nears expiry. +type GetTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + AccessToken string `protobuf:"bytes,1,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` + ExpiresIn int32 `protobuf:"varint,2,opt,name=expires_in,json=expiresIn,proto3" json:"expires_in,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTokenResponse) Reset() { + *x = GetTokenResponse{} + mi := &file_auth_v1_session_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTokenResponse) ProtoMessage() {} + +func (x *GetTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTokenResponse.ProtoReflect.Descriptor instead. +func (*GetTokenResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{5} +} + +func (x *GetTokenResponse) GetAccessToken() string { + if x != nil { + return x.AccessToken + } + return "" +} + +func (x *GetTokenResponse) GetExpiresIn() int32 { + if x != nil { + return x.ExpiresIn + } + return 0 +} + +// LogoutRequest ends the session and clears its cookie. +type LogoutRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LogoutRequest) Reset() { + *x = LogoutRequest{} + mi := &file_auth_v1_session_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LogoutRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LogoutRequest) ProtoMessage() {} + +func (x *LogoutRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. +func (*LogoutRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{6} +} + +type LogoutResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LogoutResponse) Reset() { + *x = LogoutResponse{} + mi := &file_auth_v1_session_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LogoutResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LogoutResponse) ProtoMessage() {} + +func (x *LogoutResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. +func (*LogoutResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{7} +} + +// GetSessionRequest resolves the caller behind the session cookie. It is the +// UI's sign-in check: unauthenticated means show the login page. +type GetSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSessionRequest) Reset() { + *x = GetSessionRequest{} + mi := &file_auth_v1_session_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionRequest) ProtoMessage() {} + +func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSessionRequest.ProtoReflect.Descriptor instead. +func (*GetSessionRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{8} +} + +type GetSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` + AvatarUrl string `protobuf:"bytes,4,opt,name=avatar_url,json=avatarUrl,proto3" json:"avatar_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSessionResponse) Reset() { + *x = GetSessionResponse{} + mi := &file_auth_v1_session_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionResponse) ProtoMessage() {} + +func (x *GetSessionResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSessionResponse.ProtoReflect.Descriptor instead. +func (*GetSessionResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{9} +} + +func (x *GetSessionResponse) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *GetSessionResponse) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *GetSessionResponse) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *GetSessionResponse) GetAvatarUrl() string { + if x != nil { + return x.AvatarUrl + } + return "" +} + +// RegisterRequest creates a tenant and its first admin. +type RegisterRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrgName string `protobuf:"bytes,1,opt,name=org_name,json=orgName,proto3" json:"org_name,omitempty"` + GivenName string `protobuf:"bytes,2,opt,name=given_name,json=givenName,proto3" json:"given_name,omitempty"` + FamilyName string `protobuf:"bytes,3,opt,name=family_name,json=familyName,proto3" json:"family_name,omitempty"` + Email string `protobuf:"bytes,4,opt,name=email,proto3" json:"email,omitempty"` + Password string `protobuf:"bytes,5,opt,name=password,proto3" json:"password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterRequest) Reset() { + *x = RegisterRequest{} + mi := &file_auth_v1_session_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterRequest) ProtoMessage() {} + +func (x *RegisterRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterRequest.ProtoReflect.Descriptor instead. +func (*RegisterRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{10} +} + +func (x *RegisterRequest) GetOrgName() string { + if x != nil { + return x.OrgName + } + return "" +} + +func (x *RegisterRequest) GetGivenName() string { + if x != nil { + return x.GivenName + } + return "" +} + +func (x *RegisterRequest) GetFamilyName() string { + if x != nil { + return x.FamilyName + } + return "" +} + +func (x *RegisterRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *RegisterRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +type RegisterResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + TenantId string `protobuf:"bytes,1,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegisterResponse) Reset() { + *x = RegisterResponse{} + mi := &file_auth_v1_session_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegisterResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegisterResponse) ProtoMessage() {} + +func (x *RegisterResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegisterResponse.ProtoReflect.Descriptor instead. +func (*RegisterResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{11} +} + +func (x *RegisterResponse) GetTenantId() string { + if x != nil { + return x.TenantId + } + return "" +} + +// AcceptInviteRequest redeems an invite and sets the new member's password. +// The code is the credential, which is why this RPC is public. +type AcceptInviteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Code string `protobuf:"bytes,2,opt,name=code,proto3" json:"code,omitempty"` + Password string `protobuf:"bytes,3,opt,name=password,proto3" json:"password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AcceptInviteRequest) Reset() { + *x = AcceptInviteRequest{} + mi := &file_auth_v1_session_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AcceptInviteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AcceptInviteRequest) ProtoMessage() {} + +func (x *AcceptInviteRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AcceptInviteRequest.ProtoReflect.Descriptor instead. +func (*AcceptInviteRequest) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{12} +} + +func (x *AcceptInviteRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *AcceptInviteRequest) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *AcceptInviteRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +type AcceptInviteResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AcceptInviteResponse) Reset() { + *x = AcceptInviteResponse{} + mi := &file_auth_v1_session_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AcceptInviteResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AcceptInviteResponse) ProtoMessage() {} + +func (x *AcceptInviteResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_v1_session_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AcceptInviteResponse.ProtoReflect.Descriptor instead. +func (*AcceptInviteResponse) Descriptor() ([]byte, []int) { + return file_auth_v1_session_proto_rawDescGZIP(), []int{13} +} + +var File_auth_v1_session_proto protoreflect.FileDescriptor + +const file_auth_v1_session_proto_rawDesc = "" + + "\n" + + "\x15auth/v1/session.proto\x12\aauth.v1\"\x16\n" + + "\x14GetAuthConfigRequest\"\x86\x01\n" + + "\x15GetAuthConfigResponse\x12\x16\n" + + "\x06issuer\x18\x01 \x01(\tR\x06issuer\x124\n" + + "\x16service_account_scopes\x18\x02 \x03(\tR\x14serviceAccountScopes\x12\x1f\n" + + "\vinvite_only\x18\x03 \x01(\bR\n" + + "inviteOnly\"I\n" + + "\fLoginRequest\x12\x1d\n" + + "\n" + + "login_name\x18\x01 \x01(\tR\tloginName\x12\x1a\n" + + "\bpassword\x18\x02 \x01(\tR\bpassword\"\x0f\n" + + "\rLoginResponse\"S\n" + + "\x0fGetTokenRequest\x12\x1b\n" + + "\tclient_id\x18\x01 \x01(\tR\bclientId\x12#\n" + + "\rclient_secret\x18\x02 \x01(\tR\fclientSecret\"T\n" + + "\x10GetTokenResponse\x12!\n" + + "\faccess_token\x18\x01 \x01(\tR\vaccessToken\x12\x1d\n" + + "\n" + + "expires_in\x18\x02 \x01(\x05R\texpiresIn\"\x0f\n" + + "\rLogoutRequest\"\x10\n" + + "\x0eLogoutResponse\"\x13\n" + + "\x11GetSessionRequest\"v\n" + + "\x12GetSessionResponse\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" + + "\x05email\x18\x03 \x01(\tR\x05email\x12\x1d\n" + + "\n" + + "avatar_url\x18\x04 \x01(\tR\tavatarUrl\"\x9e\x01\n" + + "\x0fRegisterRequest\x12\x19\n" + + "\borg_name\x18\x01 \x01(\tR\aorgName\x12\x1d\n" + + "\n" + + "given_name\x18\x02 \x01(\tR\tgivenName\x12\x1f\n" + + "\vfamily_name\x18\x03 \x01(\tR\n" + + "familyName\x12\x14\n" + + "\x05email\x18\x04 \x01(\tR\x05email\x12\x1a\n" + + "\bpassword\x18\x05 \x01(\tR\bpassword\"/\n" + + "\x10RegisterResponse\x12\x1b\n" + + "\ttenant_id\x18\x01 \x01(\tR\btenantId\"^\n" + + "\x13AcceptInviteRequest\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" + + "\x04code\x18\x02 \x01(\tR\x04code\x12\x1a\n" + + "\bpassword\x18\x03 \x01(\tR\bpassword\"\x16\n" + + "\x14AcceptInviteResponseB\x8e\x01\n" + + "\vcom.auth.v1B\fSessionProtoP\x01Z4github.com/galaxy-io/modules/identity/auth/v1;authv1\xa2\x02\x03AXX\xaa\x02\aAuth.V1\xca\x02\aAuth\\V1\xe2\x02\x13Auth\\V1\\GPBMetadata\xea\x02\bAuth::V1b\x06proto3" + +var ( + file_auth_v1_session_proto_rawDescOnce sync.Once + file_auth_v1_session_proto_rawDescData []byte +) + +func file_auth_v1_session_proto_rawDescGZIP() []byte { + file_auth_v1_session_proto_rawDescOnce.Do(func() { + file_auth_v1_session_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_auth_v1_session_proto_rawDesc), len(file_auth_v1_session_proto_rawDesc))) + }) + return file_auth_v1_session_proto_rawDescData +} + +var file_auth_v1_session_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_auth_v1_session_proto_goTypes = []any{ + (*GetAuthConfigRequest)(nil), // 0: auth.v1.GetAuthConfigRequest + (*GetAuthConfigResponse)(nil), // 1: auth.v1.GetAuthConfigResponse + (*LoginRequest)(nil), // 2: auth.v1.LoginRequest + (*LoginResponse)(nil), // 3: auth.v1.LoginResponse + (*GetTokenRequest)(nil), // 4: auth.v1.GetTokenRequest + (*GetTokenResponse)(nil), // 5: auth.v1.GetTokenResponse + (*LogoutRequest)(nil), // 6: auth.v1.LogoutRequest + (*LogoutResponse)(nil), // 7: auth.v1.LogoutResponse + (*GetSessionRequest)(nil), // 8: auth.v1.GetSessionRequest + (*GetSessionResponse)(nil), // 9: auth.v1.GetSessionResponse + (*RegisterRequest)(nil), // 10: auth.v1.RegisterRequest + (*RegisterResponse)(nil), // 11: auth.v1.RegisterResponse + (*AcceptInviteRequest)(nil), // 12: auth.v1.AcceptInviteRequest + (*AcceptInviteResponse)(nil), // 13: auth.v1.AcceptInviteResponse +} +var file_auth_v1_session_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_auth_v1_session_proto_init() } +func file_auth_v1_session_proto_init() { + if File_auth_v1_session_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_auth_v1_session_proto_rawDesc), len(file_auth_v1_session_proto_rawDesc)), + NumEnums: 0, + NumMessages: 14, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_auth_v1_session_proto_goTypes, + DependencyIndexes: file_auth_v1_session_proto_depIdxs, + MessageInfos: file_auth_v1_session_proto_msgTypes, + }.Build() + File_auth_v1_session_proto = out.File + file_auth_v1_session_proto_goTypes = nil + file_auth_v1_session_proto_depIdxs = nil +} diff --git a/identity/buf.gen.yaml b/identity/buf.gen.yaml new file mode 100644 index 0000000..e5789f4 --- /dev/null +++ b/identity/buf.gen.yaml @@ -0,0 +1,13 @@ +version: v2 +managed: + enabled: true + override: + - file_option: go_package_prefix + value: github.com/galaxy-io/modules/identity +plugins: + - local: protoc-gen-go + out: . + opt: paths=source_relative + - local: protoc-gen-connect-go + out: . + opt: paths=source_relative diff --git a/identity/buf.yaml b/identity/buf.yaml new file mode 100644 index 0000000..ba5ddf5 --- /dev/null +++ b/identity/buf.yaml @@ -0,0 +1,3 @@ +version: v2 +modules: + - path: proto diff --git a/identity/go.mod b/identity/go.mod new file mode 100644 index 0000000..4f6cd6f --- /dev/null +++ b/identity/go.mod @@ -0,0 +1,41 @@ +module github.com/galaxy-io/modules/identity + +go 1.26.4 + +require ( + connectrpc.com/connect v1.20.0 + github.com/coreos/go-oidc/v3 v3.17.0 + github.com/zitadel/oidc/v3 v3.47.8 + github.com/zitadel/zitadel-go/v3 v3.29.2 + golang.org/x/oauth2 v0.36.0 + google.golang.org/grpc v1.83.2 + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af +) + +require ( + github.com/bmatcuk/doublestar/v4 v4.10.4 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/go-chi/chi/v5 v5.5.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.6 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/muhlemmer/httpforwarded v0.1.0 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/zitadel/logging v0.7.0 // indirect + github.com/zitadel/schema v1.3.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.46.0 // indirect + go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.46.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.43.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260817212433-ac3dfec99bb1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260817212433-ac3dfec99bb1 // indirect +) diff --git a/identity/go.sum b/identity/go.sum new file mode 100644 index 0000000..c97605b --- /dev/null +++ b/identity/go.sum @@ -0,0 +1,99 @@ +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= +github.com/bmatcuk/doublestar/v4 v4.10.4 h1:oTKkRmRuOsKMqFe1AQazd9gGBFNEpOmgKp/xzB97dnA= +github.com/bmatcuk/doublestar/v4 v4.10.4/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +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/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +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/go-chi/chi/v5 v5.5.0 h1:xdKQ1dJmVJsmgahrXSKhUx8NyVPzpwdlyrrQFpMPJgg= +github.com/go-chi/chi/v5 v5.5.0/go.mod h1:d1IG7GtKPSjPyBP365PH8sAGVHk3qfB/IhjFNRH1wWM= +github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4= +github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +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-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +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/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= +github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= +github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/zitadel/logging v0.7.0 h1:eugftwMM95Wgqwftsvj81isL0JK/hoScVqp/7iA2adQ= +github.com/zitadel/logging v0.7.0/go.mod h1:9A6h9feBF/3u0IhA4uffdzSDY7mBaf7RE78H5sFMINQ= +github.com/zitadel/oidc/v3 v3.47.8 h1:1IcKnNsBzrUpyPyIJeNwBwa2ncrjOHQB4rdV8zuTWLk= +github.com/zitadel/oidc/v3 v3.47.8/go.mod h1:NTejBboCz6SShga9FnZiFaFiMHBOQTbGqBIP16c05Ug= +github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI= +github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw= +github.com/zitadel/zitadel-go/v3 v3.29.2 h1:4yxJG2+qwm25ykrfYKmYBPQJXXZEKKBme84fmVElXRw= +github.com/zitadel/zitadel-go/v3 v3.29.2/go.mod h1:lPi7b/gLnU/vTk1qjL8/7GT5plux5VmWoqw6U6vjPbA= +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/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +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/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.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= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260817212433-ac3dfec99bb1 h1:lrupDmKL3p5kEX1M92oan027eCKcouzjuPbH6YBK+Rs= +google.golang.org/genproto/googleapis/api v0.0.0-20260817212433-ac3dfec99bb1/go.mod h1:q/3oV3jAi5vwelxsVAprMBC8BcM2zmNe+IjRGd+9/ks= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260817212433-ac3dfec99bb1 h1:utmqiUzZAEINbLmRyAt1QjVkMyLQ+lvIwCpLaB1rZfw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260817212433-ac3dfec99bb1/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/identity/identity.go b/identity/identity.go new file mode 100644 index 0000000..58c999f --- /dev/null +++ b/identity/identity.go @@ -0,0 +1,136 @@ +// Package identity is an app's authentication port. The keycloak and +// zitadel packages are full providers an app serves; the proxy package +// trusts a gateway in front of the app instead. It is meant to be optional: +// with no provider configured an app runs unauthenticated and falls back to +// its default tenant. +package identity + +import ( + "context" + "net/http" + "slices" + + authv1 "github.com/galaxy-io/modules/identity/auth/v1" + "github.com/galaxy-io/modules/identity/auth/v1/authv1connect" +) + +// Authenticator resolves the credentials on a request to its caller. It is +// the one method the RPC interceptor needs that no service definition +// covers. +type Authenticator interface { + // Authenticate resolves the credentials on a request's headers, a bearer + // token, a session cookie or identity headers set by a trusted gateway, + // to their caller. It fails when there are none, they are invalid, or + // they carry no tenant; a *connect.Error keeps its code on the way to + // the client. + Authenticate(ctx context.Context, header http.Header) (Caller, error) +} + +// Provider is an Authenticator that also serves AuthService: login, session, +// members and service accounts. Implementing the generated handler is the +// bulk of it. An Authenticator that is not a Provider, such as a gateway +// proxy, leaves sign-in to something upstream. +type Provider interface { + Authenticator + authv1connect.AuthServiceHandler +} + +// Caller is the authenticated identity behind a request. +type Caller struct { + // UserID is the provider's stable user identifier. + UserID string + // ID is the app's own id for the user, minted from UserID within Tenant + // on first sight. Providers leave it empty; the app fills it in. + ID string + // Tenant is the app's own id for the caller's tenant, resolved from + // TenantExternalID on first sight. Providers leave it empty; the app + // fills it in. + Tenant string + // TenantExternalID is the provider's id for the tenant; a provider maps + // its own organization or workspace concept onto it. + TenantExternalID string + // TenantName is a display name for the tenant when the provider has one. + TenantName string + Roles []authv1.Role + // Service names a trusted service calling on its own behalf rather than + // a person. A service caller has a tenant but no user row. + Service string +} + +// HasRole reports whether the caller holds role. +func (c Caller) HasRole(role authv1.Role) bool { return slices.Contains(c.Roles, role) } + +// IsAdmin reports whether the caller administers their tenant. +func (c Caller) IsAdmin() bool { return c.HasRole(authv1.Role_ROLE_ADMIN) } + +// roleKeys is the wire vocabulary providers store grants under. Keeping the +// mapping here means an adapter never invents its own role names. +var roleKeys = map[authv1.Role]string{ + authv1.Role_ROLE_ADMIN: "admin", + authv1.Role_ROLE_CREATOR: "creator", + authv1.Role_ROLE_VIEWER: "viewer", +} + +// RoleKey returns the provider-facing key for role, empty when unspecified. +func RoleKey(role authv1.Role) string { return roleKeys[role] } + +// RoleKeys returns every role key the port recognizes. +func RoleKeys() []string { + keys := make([]string, 0, len(roleKeys)) + for _, role := range []authv1.Role{ + authv1.Role_ROLE_ADMIN, + authv1.Role_ROLE_CREATOR, + authv1.Role_ROLE_VIEWER, + } { + keys = append(keys, roleKeys[role]) + } + return keys +} + +// RoleFromKey maps a provider's stored grant back onto the port's +// vocabulary; unknown keys resolve to ROLE_UNSPECIFIED. +func RoleFromKey(key string) authv1.Role { + for role, roleKey := range roleKeys { + if roleKey == key { + return role + } + } + return authv1.Role_ROLE_UNSPECIFIED +} + +type ( + callerCtxKey struct{} + tenantCtxKey struct{} +) + +// WithCaller returns a context carrying the authenticated caller. +func WithCaller(ctx context.Context, caller Caller) context.Context { + return context.WithValue(ctx, callerCtxKey{}, caller) +} + +// WithTenant establishes a request's tenant without fabricating an +// authenticated caller. Auth-disabled single-tenant servers use it to place +// their configured tenant on every request context. +func WithTenant(ctx context.Context, tenant string) context.Context { + return context.WithValue(ctx, tenantCtxKey{}, tenant) +} + +// CallerFrom returns the authenticated caller, if one was established. It +// reports false whenever auth is disabled, which is how handlers know to +// fall back to the default tenant. +func CallerFrom(ctx context.Context) (Caller, bool) { + caller, ok := ctx.Value(callerCtxKey{}).(Caller) + return caller, ok +} + +// TenantFrom returns the authenticated caller's tenant, if any. +func TenantFrom(ctx context.Context) (string, bool) { + if tenant, ok := ctx.Value(tenantCtxKey{}).(string); ok && tenant != "" { + return tenant, true + } + caller, ok := CallerFrom(ctx) + if !ok || caller.Tenant == "" { + return "", false + } + return caller.Tenant, true +} diff --git a/identity/keycloak/admin.go b/identity/keycloak/admin.go new file mode 100644 index 0000000..2fc6c00 --- /dev/null +++ b/identity/keycloak/admin.go @@ -0,0 +1,413 @@ +package keycloak + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// admin is the slice of Keycloak's admin REST API the app uses, issued as +// the app client's service account. Keycloak ships no Go SDK, so the +// representations here are the fields the app reads and writes. +type admin struct { + base string + client *http.Client +} + +// apiError is a non-2xx answer from Keycloak with the reason it gave. +type apiError struct { + status int + message string +} + +func (e *apiError) Error() string { + if e.message == "" { + return fmt.Sprintf("keycloak: status %d", e.status) + } + return fmt.Sprintf("keycloak: %s", e.message) +} + +// errUnavailable marks an error that means Keycloak could not be reached at +// all, which must never be mistaken for a rejected credential. +var errUnavailable = errors.New("keycloak unavailable") + +func isStatus(err error, status int) bool { + var e *apiError + return errors.As(err, &e) && e.status == status +} + +// do issues one request. in is JSON-encoded unless it is a string, which is +// sent verbatim; out receives a JSON body when non-nil. The Location header +// comes back so a create can read the new id off it. +func (a *admin) do(ctx context.Context, method, path string, in, out any) (string, error) { + var body io.Reader + switch v := in.(type) { + case nil: + case string: + body = strings.NewReader(v) + default: + raw, err := json.Marshal(v) + if err != nil { + return "", err + } + body = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, a.base+path, body) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + res, err := a.client.Do(req) + if err != nil { + return "", fmt.Errorf("%w: %w", errUnavailable, err) + } + defer func() { _ = res.Body.Close() }() + raw, err := io.ReadAll(res.Body) + if err != nil { + return "", fmt.Errorf("%w: %w", errUnavailable, err) + } + if res.StatusCode < 200 || res.StatusCode > 299 { + return "", &apiError{status: res.StatusCode, message: reason(raw)} + } + if out != nil && len(raw) > 0 { + if err := json.Unmarshal(raw, out); err != nil { + return "", fmt.Errorf("keycloak: decode %s %s: %w", method, path, err) + } + } + return res.Header.Get("Location"), nil +} + +func decodeJSON(r io.Reader, out any) error { return json.NewDecoder(r).Decode(out) } + +// reason pulls the message out of Keycloak's two error shapes. +func reason(raw []byte) string { + var body struct { + ErrorMessage string `json:"errorMessage"` + ErrorDescription string `json:"error_description"` + Error string `json:"error"` + } + if json.Unmarshal(raw, &body) != nil { + return strings.TrimSpace(string(raw)) + } + switch { + case body.ErrorMessage != "": + return body.ErrorMessage + case body.ErrorDescription != "": + return body.ErrorDescription + } + return body.Error +} + +// created returns the id a create answered with on its Location header. +func created(location string) string { + return location[strings.LastIndex(location, "/")+1:] +} + +type userRep struct { + ID string `json:"id,omitempty"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + Enabled bool `json:"enabled"` + EmailVerified bool `json:"emailVerified"` + Attributes map[string][]string `json:"attributes,omitempty"` + Credentials []credentialRep `json:"credentials,omitempty"` +} + +type credentialRep struct { + Type string `json:"type"` + Value string `json:"value"` + Temporary bool `json:"temporary"` +} + +type organizationRep struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Alias string `json:"alias,omitempty"` +} + +type roleRep struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` +} + +type clientRep struct { + ID string `json:"id,omitempty"` + ClientID string `json:"clientId"` + Secret string `json:"secret,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + PublicClient bool `json:"publicClient"` + ServiceAccountsEnabled bool `json:"serviceAccountsEnabled"` + StandardFlowEnabled bool `json:"standardFlowEnabled"` + DirectAccessGrantsEnabled bool `json:"directAccessGrantsEnabled"` + Attributes map[string]string `json:"attributes,omitempty"` + ProtocolMappers []protocolMapperRep `json:"protocolMappers,omitempty"` +} + +type protocolMapperRep struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Protocol string `json:"protocol"` + ProtocolMapper string `json:"protocolMapper"` + Config map[string]string `json:"config"` +} + +// audienceMapper puts the app's client on a token's audience, which the +// verifier requires. +func audienceMapper(app, clientID string) protocolMapperRep { + return protocolMapperRep{ + Name: app + " audience", + Protocol: "openid-connect", + ProtocolMapper: "oidc-audience-mapper", + Config: map[string]string{ + "included.client.audience": clientID, + "access.token.claim": "true", + "id.token.claim": "false", + }, + } +} + +func (a *admin) getUser(ctx context.Context, id string) (userRep, error) { + var user userRep + _, err := a.do(ctx, http.MethodGet, "/users/"+url.PathEscape(id), nil, &user) + return user, err +} + +// getUserRaw reads a user as Keycloak represents it, for a read-modify-write: +// an update clears every field it omits. +func (a *admin) getUserRaw(ctx context.Context, id string) (map[string]any, error) { + var user map[string]any + _, err := a.do(ctx, http.MethodGet, "/users/"+url.PathEscape(id), nil, &user) + return user, err +} + +func (a *admin) putUser(ctx context.Context, id string, user map[string]any) error { + _, err := a.do(ctx, http.MethodPut, "/users/"+url.PathEscape(id), user, nil) + return err +} + +func (a *admin) createUser(ctx context.Context, user userRep) (string, error) { + location, err := a.do(ctx, http.MethodPost, "/users", user, nil) + if err != nil { + return "", err + } + return created(location), nil +} + +// userByEmail resolves a user by their exact address. +func (a *admin) userByEmail(ctx context.Context, email string) (userRep, error) { + var found []userRep + if _, err := a.do(ctx, http.MethodGet, "/users?exact=true&email="+url.QueryEscape(email), nil, &found); err != nil { + return userRep{}, err + } + if len(found) == 0 { + return userRep{}, &apiError{status: http.StatusNotFound, message: fmt.Sprintf("user %q not found", email)} + } + return found[0], nil +} + +func (a *admin) deleteUser(ctx context.Context, id string) error { + _, err := a.do(ctx, http.MethodDelete, "/users/"+url.PathEscape(id), nil, nil) + return err +} + +func (a *admin) resetPassword(ctx context.Context, id, password string) error { + _, err := a.do(ctx, http.MethodPut, "/users/"+url.PathEscape(id)+"/reset-password", + credentialRep{Type: "password", Value: password}, nil) + return err +} + +func (a *admin) createOrganization(ctx context.Context, org organizationRep) (string, error) { + location, err := a.do(ctx, http.MethodPost, "/organizations", org, nil) + if err != nil { + return "", err + } + return created(location), nil +} + +func (a *admin) deleteOrganization(ctx context.Context, id string) error { + _, err := a.do(ctx, http.MethodDelete, "/organizations/"+url.PathEscape(id), nil, nil) + return err +} + +// organizationByAlias resolves the organization behind a token's alias. +func (a *admin) organizationByAlias(ctx context.Context, alias string) (organizationRep, error) { + var found []organizationRep + if _, err := a.do(ctx, http.MethodGet, "/organizations?q="+url.QueryEscape("alias:"+alias), nil, &found); err != nil { + return organizationRep{}, err + } + for _, org := range found { + if org.Alias == alias { + return org, nil + } + } + return organizationRep{}, &apiError{status: http.StatusNotFound, message: fmt.Sprintf("organization %q not found", alias)} +} + +func (a *admin) addMember(ctx context.Context, orgID, userID string) error { + _, err := a.do(ctx, http.MethodPost, "/organizations/"+url.PathEscape(orgID)+"/members", userID, nil) + return err +} + +func (a *admin) members(ctx context.Context, orgID string) ([]userRep, error) { + var found []userRep + _, err := a.do(ctx, http.MethodGet, "/organizations/"+url.PathEscape(orgID)+"/members?max=1000", nil, &found) + return found, err +} + +func (a *admin) memberOrganizations(ctx context.Context, userID string) ([]organizationRep, error) { + var found []organizationRep + _, err := a.do(ctx, http.MethodGet, "/organizations/members/"+url.PathEscape(userID)+"/organizations", nil, &found) + return found, err +} + +// clientByClientID resolves a client's uuid from the id tokens name it by. +func (a *admin) clientByClientID(ctx context.Context, clientID string) (clientRep, error) { + var found []clientRep + if _, err := a.do(ctx, http.MethodGet, "/clients?clientId="+url.QueryEscape(clientID), nil, &found); err != nil { + return clientRep{}, err + } + for _, client := range found { + if client.ClientID == clientID { + return client, nil + } + } + return clientRep{}, &apiError{status: http.StatusNotFound, message: fmt.Sprintf("client %q not found", clientID)} +} + +// clients lists every client in the realm. Keycloak cannot search on +// attributes, so callers filter the list themselves. +func (a *admin) clients(ctx context.Context) ([]clientRep, error) { + var found []clientRep + _, err := a.do(ctx, http.MethodGet, "/clients?max=1000", nil, &found) + return found, err +} + +// getClientRaw reads a client as Keycloak represents it, for a +// read-modify-write. +func (a *admin) getClientRaw(ctx context.Context, id string) (map[string]any, error) { + var client map[string]any + _, err := a.do(ctx, http.MethodGet, "/clients/"+url.PathEscape(id), nil, &client) + return client, err +} + +func (a *admin) putClient(ctx context.Context, id string, client map[string]any) error { + _, err := a.do(ctx, http.MethodPut, "/clients/"+url.PathEscape(id), client, nil) + return err +} + +func (a *admin) createClient(ctx context.Context, client clientRep) (string, error) { + location, err := a.do(ctx, http.MethodPost, "/clients", client, nil) + if err != nil { + return "", err + } + return created(location), nil +} + +func (a *admin) deleteClient(ctx context.Context, id string) error { + _, err := a.do(ctx, http.MethodDelete, "/clients/"+url.PathEscape(id), nil, nil) + return err +} + +func (a *admin) serviceAccountUser(ctx context.Context, clientID string) (userRep, error) { + var user userRep + _, err := a.do(ctx, http.MethodGet, "/clients/"+url.PathEscape(clientID)+"/service-account-user", nil, &user) + return user, err +} + +func (a *admin) clientSecret(ctx context.Context, clientID string) (string, error) { + var secret struct { + Value string `json:"value"` + } + _, err := a.do(ctx, http.MethodGet, "/clients/"+url.PathEscape(clientID)+"/client-secret", nil, &secret) + return secret.Value, err +} + +func (a *admin) regenerateClientSecret(ctx context.Context, clientID string) (string, error) { + var secret struct { + Value string `json:"value"` + } + _, err := a.do(ctx, http.MethodPost, "/clients/"+url.PathEscape(clientID)+"/client-secret", nil, &secret) + return secret.Value, err +} + +func (a *admin) clientRoles(ctx context.Context, clientID string) ([]roleRep, error) { + var found []roleRep + _, err := a.do(ctx, http.MethodGet, "/clients/"+url.PathEscape(clientID)+"/roles", nil, &found) + return found, err +} + +func (a *admin) createClientRole(ctx context.Context, clientID, name string) error { + _, err := a.do(ctx, http.MethodPost, "/clients/"+url.PathEscape(clientID)+"/roles", roleRep{Name: name}, nil) + return err +} + +// roleUsers lists every user holding one client role, across the realm. +func (a *admin) roleUsers(ctx context.Context, clientID, role string) ([]userRep, error) { + var found []userRep + _, err := a.do(ctx, http.MethodGet, "/clients/"+url.PathEscape(clientID)+"/roles/"+url.PathEscape(role)+"/users?max=1000", nil, &found) + return found, err +} + +func (a *admin) userClientRoles(ctx context.Context, userID, clientID string) ([]roleRep, error) { + var found []roleRep + _, err := a.do(ctx, http.MethodGet, "/users/"+url.PathEscape(userID)+"/role-mappings/clients/"+url.PathEscape(clientID), nil, &found) + return found, err +} + +func (a *admin) addUserClientRoles(ctx context.Context, userID, clientID string, roles []roleRep) error { + _, err := a.do(ctx, http.MethodPost, "/users/"+url.PathEscape(userID)+"/role-mappings/clients/"+url.PathEscape(clientID), roles, nil) + return err +} + +func (a *admin) removeUserClientRoles(ctx context.Context, userID, clientID string, roles []roleRep) error { + if len(roles) == 0 { + return nil + } + _, err := a.do(ctx, http.MethodDelete, "/users/"+url.PathEscape(userID)+"/role-mappings/clients/"+url.PathEscape(clientID), roles, nil) + return err +} + +func (a *admin) protocolMappers(ctx context.Context, clientID string) ([]protocolMapperRep, error) { + var found []protocolMapperRep + _, err := a.do(ctx, http.MethodGet, "/clients/"+url.PathEscape(clientID)+"/protocol-mappers/models", nil, &found) + return found, err +} + +func (a *admin) addProtocolMapper(ctx context.Context, clientID string, mapper protocolMapperRep) error { + _, err := a.do(ctx, http.MethodPost, "/clients/"+url.PathEscape(clientID)+"/protocol-mappers/models", mapper, nil) + return err +} + +func (a *admin) realm(ctx context.Context) (map[string]any, error) { + var realm map[string]any + _, err := a.do(ctx, http.MethodGet, "", nil, &realm) + return realm, err +} + +func (a *admin) updateRealm(ctx context.Context, fields map[string]any) error { + _, err := a.do(ctx, http.MethodPut, "", fields, nil) + return err +} + +func (a *admin) userProfile(ctx context.Context) (map[string]any, error) { + var profile map[string]any + _, err := a.do(ctx, http.MethodGet, "/users/profile", nil, &profile) + return profile, err +} + +func (a *admin) updateUserProfile(ctx context.Context, profile map[string]any) error { + _, err := a.do(ctx, http.MethodPut, "/users/profile", profile, nil) + return err +} diff --git a/identity/keycloak/bootstrap.go b/identity/keycloak/bootstrap.go new file mode 100644 index 0000000..06c1d9a --- /dev/null +++ b/identity/keycloak/bootstrap.go @@ -0,0 +1,379 @@ +package keycloak + +import ( + "context" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + + "golang.org/x/oauth2" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// realmManagementRoles are the rights the app's service account needs on +// its realm: users, clients, and the realm's own settings. +var realmManagementRoles = []string{"manage-realm", "manage-users", "manage-clients", "view-realm", "view-users", "view-clients"} + +// ensureRealm creates the app's realm and client as the Keycloak admin when +// they are missing, and converges the client's secret on the configured one. +// The realm requires TLS only when the issuer itself is served over it. +func ensureRealm(ctx context.Context, server, realm string, opts Options) error { + token, err := adminToken(ctx, server, opts.AdminUsername, opts.AdminPassword) + if err != nil { + return err + } + client := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})) + realms := &admin{base: server + "/admin/realms", client: client} + if _, err := realms.do(ctx, http.MethodGet, "/"+url.PathEscape(realm), nil, nil); isStatus(err, http.StatusNotFound) { + sslRequired := "external" + if strings.HasPrefix(server, "http://") { + sslRequired = "none" + } + if _, err := realms.do(ctx, http.MethodPost, "", map[string]any{ + "realm": realm, + "enabled": true, + "sslRequired": sslRequired, + "organizationsEnabled": true, + }, nil); err != nil { + return err + } + } else if err != nil { + return err + } + a := &admin{base: server + "/admin/realms/" + realm, client: client} + found, err := a.clientByClientID(ctx, opts.ClientID) + if isStatus(err, http.StatusNotFound) { + id, err := a.createClient(ctx, clientRep{ + ClientID: opts.ClientID, + Secret: strings.TrimSpace(opts.ClientSecret), + Name: strings.ToUpper(opts.App[:1]) + opts.App[1:], + ServiceAccountsEnabled: true, + DirectAccessGrantsEnabled: true, + }) + if err != nil { + return fmt.Errorf("create client: %w", err) + } + found = clientRep{ID: id, ClientID: opts.ClientID} + } else if err != nil { + return fmt.Errorf("resolve client: %w", err) + } + current, err := a.clientSecret(ctx, found.ID) + if err != nil { + return fmt.Errorf("read client secret: %w", err) + } + if current != strings.TrimSpace(opts.ClientSecret) { + raw, err := a.getClientRaw(ctx, found.ID) + if err != nil { + return fmt.Errorf("read client: %w", err) + } + raw["secret"] = strings.TrimSpace(opts.ClientSecret) + if err := a.putClient(ctx, found.ID, raw); err != nil { + return fmt.Errorf("set client secret: %w", err) + } + } + user, err := a.serviceAccountUser(ctx, found.ID) + if err != nil { + return fmt.Errorf("resolve service account user: %w", err) + } + management, err := a.clientByClientID(ctx, "realm-management") + if err != nil { + return fmt.Errorf("resolve realm-management client: %w", err) + } + roles, err := a.clientRoles(ctx, management.ID) + if err != nil { + return fmt.Errorf("list realm-management roles: %w", err) + } + var grant []roleRep + for _, role := range roles { + if slices.Contains(realmManagementRoles, role.Name) { + grant = append(grant, role) + } + } + if err := a.addUserClientRoles(ctx, user.ID, management.ID, grant); err != nil { + return fmt.Errorf("grant realm management: %w", err) + } + return nil +} + +// adminToken signs the Keycloak admin in on the master realm. +func adminToken(ctx context.Context, server, username, password string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, tokenTimeout) + defer cancel() + form := url.Values{ + "grant_type": {"password"}, + "client_id": {"admin-cli"}, + "username": {username}, + "password": {password}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, server+"/realms/master/protocol/openid-connect/token", strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("%w: %w", errUnavailable, err) + } + defer func() { _ = res.Body.Close() }() + var token struct { + AccessToken string `json:"access_token"` + } + if res.StatusCode != http.StatusOK { + return "", fmt.Errorf("admin sign-in on master realm: status %d", res.StatusCode) + } + if err := decodeJSON(res.Body, &token); err != nil || token.AccessToken == "" { + return "", fmt.Errorf("admin sign-in on master realm: no token") + } + return token.AccessToken, nil +} + +// bootstrap converges the realm on the configuration the app needs and +// caches what later requests refer to. Every step is idempotent, so each +// boot lands on the same state. The client itself must already exist: it is +// the credential the app boots with. +func (p *Provider) bootstrap(ctx context.Context) error { + client, err := p.admin.clientByClientID(ctx, p.clientID) + if err != nil { + return fmt.Errorf("resolve client: %w", err) + } + p.client = client.ID + if err := p.ensureOrganizations(ctx); err != nil { + return err + } + if err := p.ensureRoles(ctx); err != nil { + return err + } + if err := p.ensureTokenShape(ctx, p.client); err != nil { + return err + } + return p.ensureUnmanagedAttributes(ctx) +} + +// ensureOrganizations turns the realm's organizations on; they are the +// tenants. +func (p *Provider) ensureOrganizations(ctx context.Context) error { + realm, err := p.admin.realm(ctx) + if err != nil { + return fmt.Errorf("read realm: %w", err) + } + if enabled, _ := realm["organizationsEnabled"].(bool); enabled { + return nil + } + if err := p.admin.updateRealm(ctx, map[string]any{"organizationsEnabled": true}); err != nil { + return fmt.Errorf("enable organizations: %w", err) + } + return nil +} + +// ensureRoles adds any app role the client is missing and caches +// their representations, which grants need. +func (p *Provider) ensureRoles(ctx context.Context) error { + existing, err := p.admin.clientRoles(ctx, p.client) + if err != nil { + return fmt.Errorf("list client roles: %w", err) + } + have := make(map[string]roleRep, len(existing)) + for _, role := range existing { + have[role.Name] = role + } + for _, key := range identity.RoleKeys() { + if _, ok := have[key]; ok { + continue + } + if err := p.admin.createClientRole(ctx, p.client, key); err != nil { + return fmt.Errorf("add client role %s: %w", key, err) + } + } + created, err := p.admin.clientRoles(ctx, p.client) + if err != nil { + return fmt.Errorf("list client roles: %w", err) + } + p.roles = make(map[string]roleRep, len(created)) + for _, role := range created { + p.roles[role.Name] = role + } + return nil +} + +// ensureTokenShape makes a client's tokens verifiable by the app: they name +// the app as an audience. The app's own client and every service account +// go through it. The organization membership is not part of the shape: +// Keycloak keeps that scope optional whatever a client's defaults say, so +// every grant the app makes or advertises requests it explicitly. +func (p *Provider) ensureTokenShape(ctx context.Context, clientID string) error { + mappers, err := p.admin.protocolMappers(ctx, clientID) + if err != nil { + return fmt.Errorf("list protocol mappers: %w", err) + } + want := audienceMapper(p.app, p.clientID) + hasAudience := false + for _, mapper := range mappers { + if mapper.ProtocolMapper == want.ProtocolMapper && mapper.Config["included.client.audience"] == p.clientID { + hasAudience = true + break + } + } + if !hasAudience { + if err := p.admin.addProtocolMapper(ctx, clientID, want); err != nil { + return fmt.Errorf("add audience mapper: %w", err) + } + } + return nil +} + +// ensureUnmanagedAttributes lets the app keep its own attributes on users, +// which invitations rely on. Keycloak drops attributes its user profile +// does not declare unless the realm allows administrators to set them. +func (p *Provider) ensureUnmanagedAttributes(ctx context.Context) error { + profile, err := p.admin.userProfile(ctx) + if err != nil { + return fmt.Errorf("read user profile: %w", err) + } + if policy, _ := profile["unmanagedAttributePolicy"].(string); policy == "ADMIN_EDIT" || policy == "ENABLED" { + return nil + } + profile["unmanagedAttributePolicy"] = "ADMIN_EDIT" + if err := p.admin.updateUserProfile(ctx, profile); err != nil { + return fmt.Errorf("allow unmanaged attributes: %w", err) + } + return nil +} + +// grantRole gives a user one app role, replacing any it held. +func (p *Provider) grantRole(ctx context.Context, userID, roleKey string) error { + held, err := p.admin.userClientRoles(ctx, userID, p.client) + if err != nil { + return err + } + if err := p.admin.removeUserClientRoles(ctx, userID, p.client, held); err != nil { + return err + } + role, ok := p.roles[roleKey] + if !ok { + return fmt.Errorf("unknown role %q", roleKey) + } + return p.admin.addUserClientRoles(ctx, userID, p.client, []roleRep{role}) +} + +// bootstrapTenant converges the tenant and whoever gets in first. Nothing +// changes when it is all already there. +func (p *Provider) bootstrapTenant(ctx context.Context, b Bootstrap) error { + tenant := alias(b.Tenant) + org, err := p.admin.organizationByAlias(ctx, tenant) + if isStatus(err, http.StatusNotFound) { + id, err := p.admin.createOrganization(ctx, organizationRep{Name: b.Tenant, Alias: tenant}) + if err != nil { + return fmt.Errorf("create organization: %w", err) + } + org = organizationRep{ID: id, Alias: tenant} + } else if err != nil { + return fmt.Errorf("resolve organization: %w", err) + } + if b.ClientID != "" { + if err := p.bootstrapServiceAccount(ctx, org, b); err != nil { + return err + } + } + if b.AdminEmail != "" { + return p.bootstrapAdmin(ctx, org, b) + } + return nil +} + +// bootstrapServiceAccount converges the admin service account that drives +// the tenant, with the secret the deployer chose. An SDK can then act on the +// deployment before any person has signed in. A secret rotated elsewhere is +// set back on the next boot, since the configured value is the one the +// deployer holds. +func (p *Provider) bootstrapServiceAccount(ctx context.Context, org organizationRep, b Bootstrap) error { + attributes := map[string]string{p.managedKey(): "true", p.tenantKey(): org.Alias} + client, err := p.admin.clientByClientID(ctx, b.ClientID) + if isStatus(err, http.StatusNotFound) { + id, err := p.admin.createClient(ctx, clientRep{ + ClientID: b.ClientID, + Secret: b.ClientSecret, + Name: b.ClientID, + ServiceAccountsEnabled: true, + Attributes: attributes, + }) + if err != nil { + return fmt.Errorf("create client: %w", err) + } + client = clientRep{ID: id, ClientID: b.ClientID, Attributes: attributes} + } else if err != nil { + return fmt.Errorf("resolve client: %w", err) + } + // A client the app did not create, or one that belongs to another + // tenant, is never taken over. + if client.Attributes[p.managedKey()] != "true" || client.Attributes[p.tenantKey()] != org.Alias { + return fmt.Errorf("client %q exists and is not this tenant's service account", b.ClientID) + } + current, err := p.admin.clientSecret(ctx, client.ID) + if err != nil { + return fmt.Errorf("read client secret: %w", err) + } + if current != b.ClientSecret { + raw, err := p.admin.getClientRaw(ctx, client.ID) + if err != nil { + return fmt.Errorf("read client: %w", err) + } + raw["secret"] = b.ClientSecret + if err := p.admin.putClient(ctx, client.ID, raw); err != nil { + return fmt.Errorf("set client secret: %w", err) + } + } + if err := p.ensureTokenShape(ctx, client.ID); err != nil { + return err + } + user, err := p.admin.serviceAccountUser(ctx, client.ID) + if err != nil { + return fmt.Errorf("resolve service account user: %w", err) + } + if err := p.admin.addMember(ctx, org.ID, user.ID); err != nil && !isStatus(err, http.StatusConflict) { + return fmt.Errorf("add member: %w", err) + } + if err := p.grantRole(ctx, user.ID, identity.RoleKey(authv1.Role_ROLE_ADMIN)); err != nil { + return fmt.Errorf("grant admin: %w", err) + } + return nil +} + +// bootstrapAdmin converges the person who administers the tenant with the +// password the deployer chose. A missing user is created signed-in-ready +// with the Admin role. An existing one is left alone: the members page owns +// the role from then on, and a password changed since is kept. +// +// The realm's user profile requires a name before anyone can sign in; the +// tenant supplies one until the admin is known by a better one. +func (p *Provider) bootstrapAdmin(ctx context.Context, org organizationRep, b Bootstrap) error { + _, err := p.admin.userByEmail(ctx, b.AdminEmail) + if err == nil { + return nil + } + if !isStatus(err, http.StatusNotFound) { + return fmt.Errorf("resolve admin: %w", err) + } + id, err := p.admin.createUser(ctx, userRep{ + Username: b.AdminEmail, + Email: b.AdminEmail, + FirstName: b.Tenant, + LastName: "Admin", + Enabled: true, + EmailVerified: true, + Credentials: []credentialRep{{Type: "password", Value: b.AdminPassword}}, + }) + if err != nil { + return fmt.Errorf("create admin: %w", err) + } + if err := p.admin.addMember(ctx, org.ID, id); err != nil { + return fmt.Errorf("add member: %w", p.discardUser(ctx, id, err)) + } + if err := p.grantRole(ctx, id, identity.RoleKey(authv1.Role_ROLE_ADMIN)); err != nil { + return fmt.Errorf("grant admin: %w", p.discardUser(ctx, id, err)) + } + return nil +} diff --git a/identity/keycloak/keycloak.go b/identity/keycloak/keycloak.go new file mode 100644 index 0000000..b3e5e89 --- /dev/null +++ b/identity/keycloak/keycloak.go @@ -0,0 +1,320 @@ +// Package keycloak adapts a Keycloak realm to the app's identity port. +// The app owns every screen and the AuthService contract; Keycloak stores +// credentials, issues tokens, and holds the organizations that are +// the app's tenants. The server is Keycloak's only client: browsers hold a +// session cookie and never learn the issuer, and service accounts mint +// client-credentials tokens against the realm. +// +// Human sign-in uses the direct access grant, so the app's own login page +// keeps working and nothing redirects to Keycloak. Anything Keycloak offers +// beyond a password, such as MFA or a federated login, is not reachable +// through it. +// +// Given the Keycloak admin's credentials, the app creates its realm and its +// own confidential client on boot; without them the realm's owners provide +// both, with the client's service account holding realm management rights. +// Everything else the app needs, it converges on boot either way: +// organizations, its roles on the client, the token shape, and the attribute +// policy invitations rely on. Keycloak 26 or newer. +// +// A bootstrap tenant is converged on boot with whoever gets in first: an +// admin service account for an SDK, a person with a password the deployer +// chose, or both. A bootstrap admin closes sign-up: registration is refused +// and everyone else is invited. +// +// It is a separate module so its OIDC dependencies stay out of the core +// module every connector builds against. +package keycloak + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + + "connectrpc.com/connect" + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" + "github.com/galaxy-io/modules/identity/auth/v1/authv1connect" +) + +// Options configures a Provider. +type Options struct { + // App names the app the provider serves, such as "billing". It + // prefixes the session cookie (_sid), the attributes kept on users + // and clients (.invite, .service_account, .tenant), and + // names what the provider creates for the app. + App string + // Issuer is the realm URL, the issuer its tokens carry. + Issuer string + // ClientID and ClientSecret identify the app's confidential client. Its + // service account administers the realm on the app's behalf, and every + // token the app accepts names it as an audience. + ClientID string + ClientSecret string + // UIOrigin is the origin the UI is served from; https marks the session + // cookie Secure. + UIOrigin string + // AdminUsername and AdminPassword, when set, are the Keycloak admin's + // credentials on the master realm. The app uses them once per boot to + // create its realm and client when they are missing. Both or neither. + AdminUsername string + AdminPassword string + // Bootstrap, when set, is a tenant converged on every boot with whoever + // gets in first. + Bootstrap Bootstrap +} + +// Bootstrap names a tenant and who gets in first: a service account that +// administers it, so an SDK can act before any person has signed in, a +// person who administers it with a password the deployer chose, or both. +type Bootstrap struct { + Tenant string + // ClientID and ClientSecret are the service account's; both or neither. + ClientID string + ClientSecret string + // AdminEmail and AdminPassword are the admin's; both or neither. Setting + // them closes sign-up. + AdminEmail string + AdminPassword string +} + +func (b Bootstrap) set() bool { + return b.Tenant != "" || b.ClientID != "" || b.ClientSecret != "" || b.AdminEmail != "" || b.AdminPassword != "" +} + +// complete reports whether the bootstrap names a tenant and at least one way +// into it. +func (b Bootstrap) complete() bool { + if b.Tenant == "" || (b.ClientID == "") != (b.ClientSecret == "") || (b.AdminEmail == "") != (b.AdminPassword == "") { + return false + } + return b.ClientID != "" || b.AdminEmail != "" +} + +// Provider implements the app's identity port against Keycloak. +type Provider struct { + authv1connect.UnimplementedAuthServiceHandler + + app string + issuer string + clientID string + clientSecret string + tokenEndpoint string + verifier *oidc.IDTokenVerifier + admin *admin + // client is the uuid Keycloak knows the app's client by. + client string + // roles caches the client's app roles by key; grants need the + // representation, not the name. + roles map[string]roleRep + // secureCookies marks the session cookie Secure when the UI is served + // over https. + secureCookies bool + // inviteOnly refuses registration: an admin was bootstrapped and people + // join by invitation. + inviteOnly bool + // callers caches session cookie -> cachedCaller so a browser's requests + // do not each round-trip to Keycloak. + callers sync.Map +} + +// New discovers the realm, proves the client credentials, converges the +// realm, and returns a provider ready to authenticate. +func New(ctx context.Context, opts Options) (*Provider, error) { + if opts.App == "" { + return nil, errors.New("keycloak: app is required") + } + if opts.Issuer == "" { + return nil, errors.New("keycloak: issuer is required") + } + if opts.ClientID == "" || opts.ClientSecret == "" { + return nil, errors.New("keycloak: client id and secret are required") + } + if opts.Bootstrap.set() && !opts.Bootstrap.complete() { + return nil, errors.New("keycloak: bootstrap needs a tenant and an admin email and password, a service account client id and secret, or both") + } + if (opts.AdminUsername == "") != (opts.AdminPassword == "") { + return nil, errors.New("keycloak: admin username and password go together") + } + issuer := strings.TrimRight(opts.Issuer, "/") + server, realmName, err := splitIssuer(issuer) + if err != nil { + return nil, err + } + adminBase := server + "/admin/realms/" + realmName + // The realm has to exist before discovery can find it. + if opts.AdminUsername != "" { + if err := ensureRealm(ctx, server, realmName, opts); err != nil { + return nil, fmt.Errorf("keycloak: create realm %q: %w", realmName, err) + } + } + realm, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, fmt.Errorf("keycloak: discover %s: %w", issuer, err) + } + credentials := clientcredentials.Config{ + ClientID: opts.ClientID, + ClientSecret: strings.TrimSpace(opts.ClientSecret), + TokenURL: realm.Endpoint().TokenURL, + AuthStyle: oauth2.AuthStyleInHeader, + } + // A wrong secret fails the boot, not the first request that needs it. + if _, err := credentials.Token(ctx); err != nil { + return nil, fmt.Errorf("keycloak: authenticate client %q: %w", opts.ClientID, err) + } + p := &Provider{ + app: opts.App, + issuer: issuer, + clientID: opts.ClientID, + clientSecret: credentials.ClientSecret, + tokenEndpoint: realm.Endpoint().TokenURL, + verifier: realm.Verifier(&oidc.Config{ClientID: opts.ClientID}), + // The token source outlives boot and refreshes on this context. + admin: &admin{base: adminBase, client: credentials.Client(context.WithoutCancel(ctx))}, + secureCookies: strings.HasPrefix(opts.UIOrigin, "https://"), + inviteOnly: opts.Bootstrap.AdminEmail != "", + } + if err := p.bootstrap(ctx); err != nil { + return nil, fmt.Errorf("keycloak bootstrap: %w", err) + } + if opts.Bootstrap.complete() { + if err := p.bootstrapTenant(ctx, opts.Bootstrap); err != nil { + return nil, fmt.Errorf("keycloak bootstrap tenant %q: %w", opts.Bootstrap.Tenant, err) + } + } + return p, nil +} + +// splitIssuer takes the realm's issuer apart into the server and the realm +// name, since Keycloak forms it as /realms/. +func splitIssuer(issuer string) (server, realm string, err error) { + u, err := url.Parse(issuer) + if err != nil { + return "", "", fmt.Errorf("keycloak: parse issuer %q: %w", issuer, err) + } + prefix, realm, ok := strings.Cut(u.Path, "/realms/") + if !ok || realm == "" || strings.Contains(realm, "/") { + return "", "", fmt.Errorf("keycloak: issuer %q is not a realm URL (/realms/)", issuer) + } + u.Path = prefix + return strings.TrimRight(u.String(), "/"), realm, nil +} + +// Authenticate resolves the caller behind a request. Service accounts carry +// a bearer access token verified against the realm's JWKS; browsers carry +// the session cookie Login set. +func (p *Provider) Authenticate(ctx context.Context, header http.Header) (identity.Caller, error) { + if bearer, ok := strings.CutPrefix(header.Get("Authorization"), "Bearer "); ok && bearer != "" { + return p.authenticateToken(ctx, bearer) + } + if session, ok := p.sessionFromCookie(header); ok { + return p.authenticateSession(ctx, session) + } + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("no credentials")) +} + +// claims are the parts of a Keycloak access token the app reads. The +// organization scope lists the caller's memberships by alias; the client +// roles mapper puts granted roles under the client. +type claims struct { + Subject string `json:"sub"` + Organizations []string `json:"organization"` + ResourceAccess map[string]struct { + Roles []string `json:"roles"` + } `json:"resource_access"` +} + +// authenticateToken verifies an access token and resolves the caller's +// tenant and roles from its claims. The organization alias is the tenant's +// external id: Keycloak fixes it at creation and puts nothing else on the +// token. +func (p *Provider) authenticateToken(ctx context.Context, bearer string) (identity.Caller, error) { + token, err := p.verifier.Verify(ctx, bearer) + if err != nil { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, err) + } + var c claims + if err := token.Claims(&c); err != nil { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, err) + } + if len(c.Organizations) == 0 { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("token carries no organization; request the organization scope")) + } + if len(c.Organizations) > 1 { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("token carries more than one organization; request one with the organization: scope")) + } + caller := identity.Caller{ + UserID: c.Subject, + TenantExternalID: c.Organizations[0], + TenantName: c.Organizations[0], + } + for _, key := range c.ResourceAccess[p.clientID].Roles { + if role := identity.RoleFromKey(key); role != authv1.Role_ROLE_UNSPECIFIED { + caller.Roles = append(caller.Roles, role) + } + } + if len(caller.Roles) == 0 { + return identity.Caller{}, connect.NewError(connect.CodePermissionDenied, errors.New("token carries no "+p.app+" role")) + } + return caller, nil +} + +// GetAuthConfig tells the UI that auth is on and non-interactive clients +// what to request from the issuer. +func (p *Provider) GetAuthConfig(_ context.Context, _ *connect.Request[authv1.GetAuthConfigRequest]) (*connect.Response[authv1.GetAuthConfigResponse], error) { + return connect.NewResponse(&authv1.GetAuthConfigResponse{ + Issuer: p.issuer, + ServiceAccountScopes: []string{"openid", "organization"}, + InviteOnly: p.inviteOnly, + }), nil +} + +// adminCaller resolves the caller the interceptor stashed and requires +// tenant administration. +func adminCaller(ctx context.Context) (identity.Caller, error) { + c, ok := identity.CallerFrom(ctx) + if !ok { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + if !c.IsAdmin() { + return identity.Caller{}, connect.NewError(connect.CodePermissionDenied, errors.New("only admins can manage the team")) + } + return c, nil +} + +// tenant resolves the caller's organization, which admin requests are +// scoped to. +func (p *Provider) tenant(ctx context.Context, caller identity.Caller) (organizationRep, error) { + org, err := p.admin.organizationByAlias(ctx, caller.TenantExternalID) + if err != nil { + return organizationRep{}, rpcError(err) + } + return org, nil +} + +// rpcError maps a Keycloak failure onto the code a client should see. An +// outage keeps its own code so it is never mistaken for a rejection. +func rpcError(err error) error { + var cerr *connect.Error + if errors.As(err, &cerr) { + return err + } + if errors.Is(err, errUnavailable) { + return connect.NewError(connect.CodeUnavailable, err) + } + switch { + case isStatus(err, http.StatusNotFound): + return connect.NewError(connect.CodeNotFound, err) + case isStatus(err, http.StatusConflict), isStatus(err, http.StatusBadRequest): + return connect.NewError(connect.CodeInvalidArgument, err) + } + return connect.NewError(connect.CodeInternal, err) +} diff --git a/identity/keycloak/members.go b/identity/keycloak/members.go new file mode 100644 index 0000000..0155f96 --- /dev/null +++ b/identity/keycloak/members.go @@ -0,0 +1,284 @@ +package keycloak + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "connectrpc.com/connect" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// Invitations live on the invited user as attributes: the code's hash and +// its expiry. Keycloak's own invitation flow mails the code, and the app +// sends no mail. +const ( + inviteLifetime = 7 * 24 * time.Hour + // serviceAccountPrefix is how Keycloak names a client's service account + // user, which is how one is told apart from a human in a member list. + serviceAccountPrefix = "service-account-" +) + +func isServiceAccount(user userRep) bool { + return strings.HasPrefix(user.Username, serviceAccountPrefix) +} + +// rolesByUser maps every holder of an app role to the role they hold. +func (p *Provider) rolesByUser(ctx context.Context) (map[string]authv1.Role, error) { + roles := map[string]authv1.Role{} + for _, key := range identity.RoleKeys() { + holders, err := p.admin.roleUsers(ctx, p.client, key) + if err != nil { + return nil, err + } + for _, user := range holders { + roles[user.ID] = identity.RoleFromKey(key) + } + } + return roles, nil +} + +// ListMembers returns the tenant's users joined with the app role each +// holds. The organization's member list names them; a per-user read fills +// in the profile it leaves out. +func (p *Provider) ListMembers(ctx context.Context, _ *connect.Request[authv1.ListMembersRequest]) (*connect.Response[authv1.ListMembersResponse], error) { + caller, ok := identity.CallerFrom(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + org, err := p.tenant(ctx, caller) + if err != nil { + return nil, err + } + members, err := p.admin.members(ctx, org.ID) + if err != nil { + return nil, rpcError(err) + } + roles, err := p.rolesByUser(ctx) + if err != nil { + return nil, rpcError(err) + } + out := make([]*authv1.Member, 0, len(members)) + for _, member := range members { + if isServiceAccount(member) { + continue + } + user, err := p.admin.getUser(ctx, member.ID) + if err != nil { + return nil, rpcError(err) + } + out = append(out, &authv1.Member{ + UserId: user.ID, + Name: displayName(user), + Email: user.Email, + Role: roles[user.ID], + }) + } + return connect.NewResponse(&authv1.ListMembersResponse{ + Members: out, + CanManage: caller.IsAdmin(), + }), nil +} + +// requireMember confirms a user belongs to the caller's tenant, so one +// tenant can never reach another's members. +func (p *Provider) requireMember(ctx context.Context, org organizationRep, userID string) error { + orgs, err := p.admin.memberOrganizations(ctx, userID) + if err != nil { + if isStatus(err, 404) { + return connect.NewError(connect.CodeNotFound, errors.New("member not found")) + } + return rpcError(err) + } + for _, candidate := range orgs { + if candidate.ID == org.ID { + return nil + } + } + return connect.NewError(connect.CodeNotFound, errors.New("member not found")) +} + +// InviteMember creates a teammate in the caller's tenant and returns the +// code that redeems their invitation. Nothing is emailed; the admin shares +// the link. +func (p *Provider) InviteMember(ctx context.Context, req *connect.Request[authv1.InviteMemberRequest]) (*connect.Response[authv1.InviteMemberResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + m := req.Msg + if m.GetEmail() == "" || m.GetGivenName() == "" || m.GetFamilyName() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("email, given_name, and family_name are required")) + } + roleKey := identity.RoleKey(m.GetRole()) + if roleKey == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role is required")) + } + org, err := p.tenant(ctx, caller) + if err != nil { + return nil, err + } + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + code := hex.EncodeToString(raw[:]) + userID, err := p.admin.createUser(ctx, userRep{ + Username: m.GetEmail(), + Email: m.GetEmail(), + FirstName: m.GetGivenName(), + LastName: m.GetFamilyName(), + Enabled: true, + Attributes: map[string][]string{ + p.inviteHashKey(): {inviteHash(code)}, + p.inviteExpiresKey(): {strconv.FormatInt(time.Now().Add(inviteLifetime).Unix(), 10)}, + }, + }) + if err != nil { + return nil, rpcError(err) + } + if err := p.admin.addMember(ctx, org.ID, userID); err != nil { + return nil, rpcError(p.discardUser(ctx, userID, err)) + } + if err := p.grantRole(ctx, userID, roleKey); err != nil { + return nil, rpcError(p.discardUser(ctx, userID, err)) + } + return connect.NewResponse(&authv1.InviteMemberResponse{ + UserId: userID, + Code: code, + }), nil +} + +func inviteHash(code string) string { + sum := sha256.Sum256([]byte(code)) + return hex.EncodeToString(sum[:]) +} + +// discardUser deletes a user whose creation did not complete, so the email +// is free for a retry rather than locked behind a half-created account. It +// runs detached from the request so a dropped client still triggers cleanup. +// cause is returned as-is on success; a failed delete is joined onto it, since +// the admin then has to remove the user by hand. +func (p *Provider) discardUser(ctx context.Context, userID string, cause error) error { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if err := p.admin.deleteUser(ctx, userID); err != nil { + return errors.Join(cause, fmt.Errorf("cleanup of user %s failed, remove it manually: %w", userID, err)) + } + return cause +} + +// AcceptInvite redeems an invitation and sets the member's password. The +// code is the credential, which is why this RPC is public. +func (p *Provider) AcceptInvite(ctx context.Context, req *connect.Request[authv1.AcceptInviteRequest]) (*connect.Response[authv1.AcceptInviteResponse], error) { + m := req.Msg + if m.GetUserId() == "" || m.GetCode() == "" || m.GetPassword() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id, code, and password are required")) + } + invalid := connect.NewError(connect.CodeUnauthenticated, errors.New("invalid or expired invite")) + user, err := p.admin.getUserRaw(ctx, m.GetUserId()) + if err != nil { + if errors.Is(err, errUnavailable) { + return nil, connect.NewError(connect.CodeUnavailable, err) + } + return nil, invalid + } + attributes, _ := user["attributes"].(map[string]any) + hash := attribute(attributes, p.inviteHashKey()) + expires, _ := strconv.ParseInt(attribute(attributes, p.inviteExpiresKey()), 10, 64) + if hash == "" || subtle.ConstantTimeCompare([]byte(hash), []byte(inviteHash(m.GetCode()))) != 1 || time.Now().Unix() > expires { + return nil, invalid + } + if err := p.admin.resetPassword(ctx, m.GetUserId(), m.GetPassword()); err != nil { + // Password-policy failures surface with Keycloak's reason. + return nil, rpcError(err) + } + // The code is spent, and the address it went to is now proven. + delete(attributes, p.inviteHashKey()) + delete(attributes, p.inviteExpiresKey()) + user["attributes"] = attributes + user["emailVerified"] = true + if err := p.admin.putUser(ctx, m.GetUserId(), user); err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.AcceptInviteResponse{}), nil +} + +// attribute reads the first value of a user attribute as Keycloak returns +// them: a list per key. +func attribute(attributes map[string]any, key string) string { + values, _ := attributes[key].([]any) + if len(values) == 0 { + return "" + } + value, _ := values[0].(string) + return value +} + +// SetMemberRole reassigns a member's role. +func (p *Provider) SetMemberRole(ctx context.Context, req *connect.Request[authv1.SetMemberRoleRequest]) (*connect.Response[authv1.SetMemberRoleResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + m := req.Msg + if m.GetUserId() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + roleKey := identity.RoleKey(m.GetRole()) + if roleKey == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role is required")) + } + org, err := p.tenant(ctx, caller) + if err != nil { + return nil, err + } + if err := p.requireMember(ctx, org, m.GetUserId()); err != nil { + return nil, err + } + if err := p.grantRole(ctx, m.GetUserId(), roleKey); err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.SetMemberRoleResponse{}), nil +} + +// RemoveMember deletes a member of the caller's tenant. Callers cannot +// remove themselves, and members of other tenants are unreachable. +func (p *Provider) RemoveMember(ctx context.Context, req *connect.Request[authv1.RemoveMemberRequest]) (*connect.Response[authv1.RemoveMemberResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + userID := req.Msg.GetUserId() + if userID == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + if userID == caller.UserID { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("you cannot remove yourself")) + } + org, err := p.tenant(ctx, caller) + if err != nil { + return nil, err + } + if err := p.requireMember(ctx, org, userID); err != nil { + return nil, err + } + if err := p.admin.deleteUser(ctx, userID); err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.RemoveMemberResponse{}), nil +} + +// inviteHashKey and inviteExpiresKey are the user attributes an invitation +// lives in. +func (p *Provider) inviteHashKey() string { return p.app + ".invite" } +func (p *Provider) inviteExpiresKey() string { return p.app + ".invite_expires" } diff --git a/identity/keycloak/service_accounts.go b/identity/keycloak/service_accounts.go new file mode 100644 index 0000000..b151d6e --- /dev/null +++ b/identity/keycloak/service_accounts.go @@ -0,0 +1,243 @@ +package keycloak + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "net/url" + "strings" + + "connectrpc.com/connect" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// A service account is a confidential client of its own, minting +// client-credentials tokens. Its service-account user is a member of the +// tenant and holds the app role, so its tokens read like a person's. +// Attributes mark the clients the app manages and the tenant each belongs +// to. +func (p *Provider) serviceAccountClientPrefix() string { return p.app + "-sa-" } +func (p *Provider) managedKey() string { return p.app + ".service_account" } +func (p *Provider) tenantKey() string { return p.app + ".tenant" } + +// GetToken runs the client-credentials grant for a service account. Proxying +// it keeps the scopes and the issuer the server's business, so a client only +// ever holds its id and secret. +func (p *Provider) GetToken(ctx context.Context, req *connect.Request[authv1.GetTokenRequest]) (*connect.Response[authv1.GetTokenResponse], error) { + m := req.Msg + if m.GetClientId() == "" || m.GetClientSecret() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("client_id and client_secret are required")) + } + // The organization scope is what puts the tenant on the token; Keycloak + // never grants it by default. + token, err := p.grantAs(ctx, m.GetClientId(), m.GetClientSecret(), url.Values{ + "grant_type": {"client_credentials"}, + "scope": {"openid organization"}, + }) + if errors.Is(err, errGrantRejected) { + // Unknown clients and wrong secrets both land here; neither should + // tell the caller which it was. + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid credentials")) + } + if err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.GetTokenResponse{ + AccessToken: token.AccessToken, + ExpiresIn: token.ExpiresIn, + }), nil +} + +// managedServiceAccounts lists the clients the app manages for a tenant. +func (p *Provider) managedServiceAccounts(ctx context.Context, tenant string) ([]clientRep, error) { + found, err := p.admin.clients(ctx) + if err != nil { + return nil, err + } + var own []clientRep + for _, client := range found { + if client.Attributes[p.managedKey()] == "true" && client.Attributes[p.tenantKey()] == tenant { + own = append(own, client) + } + } + return own, nil +} + +// ListServiceAccounts returns the tenant's service accounts. Client secrets +// are only readable by an admin of the tenant through create and rotate and +// are not part of the response. +func (p *Provider) ListServiceAccounts(ctx context.Context, _ *connect.Request[authv1.ListServiceAccountsRequest]) (*connect.Response[authv1.ListServiceAccountsResponse], error) { + caller, ok := identity.CallerFrom(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + clients, err := p.managedServiceAccounts(ctx, caller.TenantExternalID) + if err != nil { + return nil, rpcError(err) + } + roles, err := p.rolesByUser(ctx) + if err != nil { + return nil, rpcError(err) + } + accounts := make([]*authv1.ServiceAccount, 0, len(clients)) + for _, client := range clients { + user, err := p.admin.serviceAccountUser(ctx, client.ID) + if err != nil { + return nil, rpcError(err) + } + accounts = append(accounts, &authv1.ServiceAccount{ + UserId: user.ID, + ClientId: client.ClientID, + Name: client.Name, + Description: client.Description, + Role: roles[user.ID], + }) + } + return connect.NewResponse(&authv1.ListServiceAccountsResponse{ + ServiceAccounts: accounts, + CanManage: caller.IsAdmin(), + }), nil +} + +// CreateServiceAccount creates a client for the account, puts its user in +// the tenant with the app role, and returns the client secret exactly +// once. +func (p *Provider) CreateServiceAccount(ctx context.Context, req *connect.Request[authv1.CreateServiceAccountRequest]) (*connect.Response[authv1.CreateServiceAccountResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + name := strings.TrimSpace(req.Msg.GetName()) + if name == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name is required")) + } + roleKey := identity.RoleKey(req.Msg.GetRole()) + if roleKey == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role is required")) + } + org, err := p.tenant(ctx, caller) + if err != nil { + return nil, err + } + var raw [8]byte + if _, err := rand.Read(raw[:]); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + description := strings.TrimSpace(req.Msg.GetDescription()) + clientID := p.serviceAccountClientPrefix() + hex.EncodeToString(raw[:]) + id, err := p.admin.createClient(ctx, clientRep{ + ClientID: clientID, + Name: name, + Description: description, + ServiceAccountsEnabled: true, + Attributes: map[string]string{p.managedKey(): "true", p.tenantKey(): caller.TenantExternalID}, + }) + if err != nil { + return nil, rpcError(err) + } + cleanup := func() { _ = p.admin.deleteClient(ctx, id) } + if err := p.ensureTokenShape(ctx, id); err != nil { + cleanup() + return nil, rpcError(err) + } + user, err := p.admin.serviceAccountUser(ctx, id) + if err != nil { + cleanup() + return nil, rpcError(err) + } + if err := p.admin.addMember(ctx, org.ID, user.ID); err != nil { + cleanup() + return nil, rpcError(err) + } + if err := p.grantRole(ctx, user.ID, roleKey); err != nil { + cleanup() + return nil, rpcError(err) + } + secret, err := p.admin.clientSecret(ctx, id) + if err != nil { + cleanup() + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.CreateServiceAccountResponse{ + ServiceAccount: &authv1.ServiceAccount{ + UserId: user.ID, + ClientId: clientID, + Name: name, + Description: description, + Role: req.Msg.GetRole(), + }, + ClientSecret: secret, + }), nil +} + +// requireServiceAccount resolves the client behind a service account's user +// id, refusing users that are not the app-managed accounts of the caller's +// tenant. +func (p *Provider) requireServiceAccount(ctx context.Context, tenant, userID string) (clientRep, error) { + notFound := connect.NewError(connect.CodeNotFound, errors.New("service account not found")) + user, err := p.admin.getUser(ctx, userID) + if err != nil { + if errors.Is(err, errUnavailable) { + return clientRep{}, connect.NewError(connect.CodeUnavailable, err) + } + return clientRep{}, notFound + } + if !isServiceAccount(user) { + return clientRep{}, notFound + } + client, err := p.admin.clientByClientID(ctx, strings.TrimPrefix(user.Username, serviceAccountPrefix)) + if err != nil { + return clientRep{}, rpcError(err) + } + if client.Attributes[p.managedKey()] != "true" || client.Attributes[p.tenantKey()] != tenant { + return clientRep{}, notFound + } + return client, nil +} + +// RotateServiceAccountSecret invalidates the current client secret and returns +// its replacement exactly once. +func (p *Provider) RotateServiceAccountSecret(ctx context.Context, req *connect.Request[authv1.RotateServiceAccountSecretRequest]) (*connect.Response[authv1.RotateServiceAccountSecretResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + if req.Msg.GetUserId() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + client, err := p.requireServiceAccount(ctx, caller.TenantExternalID, req.Msg.GetUserId()) + if err != nil { + return nil, err + } + secret, err := p.admin.regenerateClientSecret(ctx, client.ID) + if err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.RotateServiceAccountSecretResponse{ + ClientId: client.ClientID, + ClientSecret: secret, + }), nil +} + +// RemoveServiceAccount deletes a service account's client, and its user with +// it. +func (p *Provider) RemoveServiceAccount(ctx context.Context, req *connect.Request[authv1.RemoveServiceAccountRequest]) (*connect.Response[authv1.RemoveServiceAccountResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + if req.Msg.GetUserId() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + client, err := p.requireServiceAccount(ctx, caller.TenantExternalID, req.Msg.GetUserId()) + if err != nil { + return nil, err + } + if err := p.admin.deleteClient(ctx, client.ID); err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.RemoveServiceAccountResponse{}), nil +} diff --git a/identity/keycloak/session.go b/identity/keycloak/session.go new file mode 100644 index 0000000..79486ea --- /dev/null +++ b/identity/keycloak/session.go @@ -0,0 +1,290 @@ +package keycloak + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "connectrpc.com/connect" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// Login exchanges the credentials for an offline refresh token and hands the +// browser that token in an HttpOnly cookie. The token is the credential every +// later RPC carries: each request mints a short-lived access token from it, +// so nothing about Keycloak reaches the browser. Offline tokens outlive the +// realm's SSO session, and Logout revokes them. +const ( + sessionLifetime = 30 * 24 * time.Hour + // callerCacheTTL bounds how long a resolved session skips Keycloak; role + // changes and logouts from elsewhere take effect within it. + callerCacheTTL = time.Minute + // sessionScopes asks for the organization the tenant comes from and an + // offline token the cookie can hold. + sessionScopes = "openid organization offline_access" +) + +// sessionCookie builds the session cookie; an empty value with a negative +// maxAge clears it. +func (p *Provider) sessionCookie(value string, maxAge int) *http.Cookie { + return &http.Cookie{ //nolint:gosec // Secure follows the UI origin's scheme so plain-http local dev keeps its cookie + Name: p.sessionCookieName(), + Value: value, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: p.secureCookies, + SameSite: http.SameSiteLaxMode, + } +} + +// sessionFromCookie reads the refresh token off a request, reporting false +// when there is none. +func (p *Provider) sessionFromCookie(header http.Header) (string, bool) { + cookie, err := (&http.Request{Header: header}).Cookie(p.sessionCookieName()) + if err != nil || cookie.Value == "" { + return "", false + } + return cookie.Value, true +} + +// cachedCaller is a resolved session that skips Keycloak until expires. +type cachedCaller struct { + caller identity.Caller + expires time.Time +} + +// tokenResponse is the token endpoint's answer to a grant. +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int32 `json:"expires_in"` +} + +// errGrantRejected means the token endpoint refused the grant: wrong +// credentials, a revoked token, or an account that cannot sign in. +var errGrantRejected = errors.New("grant rejected") + +// tokenTimeout bounds each call to the token endpoint. +const tokenTimeout = 10 * time.Second + +// grant runs one grant against the token endpoint as the app's client. +func (p *Provider) grant(ctx context.Context, form url.Values) (tokenResponse, error) { + return p.grantAs(ctx, p.clientID, p.clientSecret, form) +} + +// grantAs runs one grant against the token endpoint as the given client. +func (p *Provider) grantAs(ctx context.Context, clientID, clientSecret string, form url.Values) (tokenResponse, error) { + ctx, cancel := context.WithTimeout(ctx, tokenTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.tokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return tokenResponse{}, err + } + req.SetBasicAuth(clientID, clientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + res, err := http.DefaultClient.Do(req) + if err != nil { + return tokenResponse{}, fmt.Errorf("%w: %w", errUnavailable, err) + } + defer func() { _ = res.Body.Close() }() + raw, err := io.ReadAll(res.Body) + if err != nil { + return tokenResponse{}, fmt.Errorf("%w: %w", errUnavailable, err) + } + if res.StatusCode >= 500 { + return tokenResponse{}, fmt.Errorf("%w: token endpoint status %d", errUnavailable, res.StatusCode) + } + if res.StatusCode != http.StatusOK { + return tokenResponse{}, fmt.Errorf("%w: %s", errGrantRejected, reason(raw)) + } + var token tokenResponse + if err := json.Unmarshal(raw, &token); err != nil { + return tokenResponse{}, fmt.Errorf("keycloak: decode token: %w", err) + } + return token, nil +} + +// revoke invalidates a refresh token; best effort, the cookie goes away +// regardless. +func (p *Provider) revoke(ctx context.Context, refreshToken string) { + form := url.Values{"token": {refreshToken}, "token_type_hint": {"refresh_token"}} + ctx, cancel := context.WithTimeout(ctx, tokenTimeout) + defer cancel() + endpoint := strings.TrimSuffix(p.tokenEndpoint, "/token") + "/revoke" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return + } + req.SetBasicAuth(p.clientID, p.clientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if res, err := http.DefaultClient.Do(req); err == nil { + _ = res.Body.Close() + } +} + +// authenticateSession resolves the caller behind a session cookie by minting +// an access token from its refresh token and reading that token's claims. +func (p *Provider) authenticateSession(ctx context.Context, refreshToken string) (identity.Caller, error) { + now := time.Now() + if cached, ok := p.callers.Load(refreshToken); ok && now.Before(cached.(cachedCaller).expires) { + return cached.(cachedCaller).caller, nil + } + token, err := p.grant(ctx, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {refreshToken}}) + if err != nil { + if errors.Is(err, errUnavailable) { + return identity.Caller{}, connect.NewError(connect.CodeUnavailable, err) + } + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid session")) + } + caller, err := p.authenticateToken(ctx, token.AccessToken) + if err != nil { + return identity.Caller{}, err + } + p.rememberCaller(refreshToken, caller, now) + return caller, nil +} + +// rememberCaller caches a resolved session and drops any entries that have +// expired; logins are rare enough that the sweep costs nothing. +func (p *Provider) rememberCaller(key string, caller identity.Caller, now time.Time) { + p.callers.Range(func(k, value any) bool { + if !now.Before(value.(cachedCaller).expires) { + p.callers.Delete(k) + } + return true + }) + p.callers.Store(key, cachedCaller{caller: caller, expires: now.Add(callerCacheTTL)}) +} + +// Login checks the credentials with the direct access grant and answers with +// the cookie that carries the session. Proxying keeps Keycloak off the +// browser's origin and the flow in one place. +func (p *Provider) Login(ctx context.Context, req *connect.Request[authv1.LoginRequest]) (*connect.Response[authv1.LoginResponse], error) { + m := req.Msg + if m.GetLoginName() == "" || m.GetPassword() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("login_name and password are required")) + } + token, err := p.grant(ctx, url.Values{ + "grant_type": {"password"}, + "username": {m.GetLoginName()}, + "password": {m.GetPassword()}, + "scope": {sessionScopes}, + }) + if err != nil { + if errors.Is(err, errUnavailable) { + return nil, connect.NewError(connect.CodeUnavailable, err) + } + // Unknown users and wrong passwords both land here; neither should + // tell the caller which it was. + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid credentials")) + } + res := connect.NewResponse(&authv1.LoginResponse{}) + res.Header().Add("Set-Cookie", p.sessionCookie(token.RefreshToken, int(sessionLifetime.Seconds())).String()) + return res, nil +} + +// Logout revokes the session's token and clears its cookie. +func (p *Provider) Logout(ctx context.Context, req *connect.Request[authv1.LogoutRequest]) (*connect.Response[authv1.LogoutResponse], error) { + if session, ok := p.sessionFromCookie(req.Header()); ok { + p.callers.Delete(session) + p.revoke(ctx, session) + } + res := connect.NewResponse(&authv1.LogoutResponse{}) + res.Header().Add("Set-Cookie", p.sessionCookie("", -1).String()) + return res, nil +} + +// GetSession describes the caller behind the request for the UI's own +// account surfaces. Service accounts have no human profile and answer with +// their id alone. +func (p *Provider) GetSession(ctx context.Context, _ *connect.Request[authv1.GetSessionRequest]) (*connect.Response[authv1.GetSessionResponse], error) { + caller, ok := identity.CallerFrom(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + user, err := p.admin.getUser(ctx, caller.UserID) + if err != nil { + return nil, rpcError(err) + } + return connect.NewResponse(&authv1.GetSessionResponse{ + UserId: caller.UserID, + Name: displayName(user), + Email: user.Email, + }), nil +} + +func displayName(user userRep) string { + return strings.TrimSpace(user.FirstName + " " + user.LastName) +} + +// aliasChars is everything an organization alias cannot keep. +var aliasChars = regexp.MustCompile(`[^a-z0-9]+`) + +// alias derives an organization's alias from its name. Keycloak fixes the +// alias at creation, which is what makes it usable as the tenant id. +func alias(name string) string { + a := strings.Trim(aliasChars.ReplaceAllString(strings.ToLower(name), "-"), "-") + if a == "" { + return "org" + } + return a +} + +// Register creates an organization and its first admin: the app's +// self-service signup. The organization becomes the tenant. An invite-only +// deployment refuses. +func (p *Provider) Register(ctx context.Context, req *connect.Request[authv1.RegisterRequest]) (*connect.Response[authv1.RegisterResponse], error) { + if p.inviteOnly { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("sign-up is closed on this deployment; ask an admin for an invitation")) + } + m := req.Msg + if m.GetOrgName() == "" || m.GetGivenName() == "" || m.GetFamilyName() == "" || m.GetEmail() == "" || m.GetPassword() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("org_name, given_name, family_name, email, and password are required")) + } + orgID, err := p.admin.createOrganization(ctx, organizationRep{Name: m.GetOrgName(), Alias: alias(m.GetOrgName())}) + if err != nil { + if isStatus(err, http.StatusConflict) { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("an organization with that name already exists")) + } + return nil, rpcError(err) + } + // The app sends no mail, so the founder's address starts verified and + // they can sign in immediately. + userID, err := p.admin.createUser(ctx, userRep{ + Username: m.GetEmail(), + Email: m.GetEmail(), + FirstName: m.GetGivenName(), + LastName: m.GetFamilyName(), + Enabled: true, + EmailVerified: true, + Credentials: []credentialRep{{Type: "password", Value: m.GetPassword()}}, + }) + if err != nil { + // Duplicate emails and password-policy failures both land here + // with Keycloak's reason attached; the organization goes with them + // so the name is free for a retry. + _ = p.admin.deleteOrganization(ctx, orgID) + return nil, rpcError(err) + } + if err := p.admin.addMember(ctx, orgID, userID); err != nil { + return nil, rpcError(p.discardUser(ctx, userID, err)) + } + // The founder administers the tenant they just created. + if err := p.grantRole(ctx, userID, identity.RoleKey(authv1.Role_ROLE_ADMIN)); err != nil { + return nil, rpcError(p.discardUser(ctx, userID, err)) + } + return connect.NewResponse(&authv1.RegisterResponse{TenantId: alias(m.GetOrgName())}), nil +} + +// sessionCookieName is the cookie the browser holds its session in. +func (p *Provider) sessionCookieName() string { return p.app + "_sid" } diff --git a/identity/proto/auth/v1/members.proto b/identity/proto/auth/v1/members.proto new file mode 100644 index 0000000..7eb0619 --- /dev/null +++ b/identity/proto/auth/v1/members.proto @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package auth.v1; + +// Role is an app permission level. The vocabulary is the app's, not the +// provider's: an adapter maps whatever grants it stores onto these. +enum Role { + ROLE_UNSPECIFIED = 0; + ROLE_ADMIN = 1; + ROLE_CREATOR = 2; + ROLE_VIEWER = 3; +} + +// Member is one user of a tenant. +message Member { + string user_id = 1; + string name = 2; + string email = 3; + Role role = 4; +} + +message ListMembersRequest {} + +message ListMembersResponse { + repeated Member members = 1; + // can_manage reports whether the caller may invite, re-role, or remove. + // The UI renders read-only when false. + bool can_manage = 2; +} + +message InviteMemberRequest { + string email = 1; + string given_name = 2; + string family_name = 3; + Role role = 4; +} + +// InviteMemberResponse carries the invitation an admin shares out of band. +// Nothing is emailed. +message InviteMemberResponse { + string user_id = 1; + string code = 2; +} + +message SetMemberRoleRequest { + string user_id = 1; + Role role = 2; +} + +message SetMemberRoleResponse {} + +message RemoveMemberRequest { + string user_id = 1; +} + +message RemoveMemberResponse {} diff --git a/identity/proto/auth/v1/service.proto b/identity/proto/auth/v1/service.proto new file mode 100644 index 0000000..545d2f8 --- /dev/null +++ b/identity/proto/auth/v1/service.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +package auth.v1; + +import "auth/v1/members.proto"; +import "auth/v1/service_accounts.proto"; +import "auth/v1/session.proto"; + +// AuthService is an app's identity surface. It is optional: with no +// provider configured the server leaves it unimplemented and the API is +// unauthenticated. +// +// The session RPCs run before the caller holds a bearer token, so they are +// the server's only public procedures and carry no tenant_id — there is no +// tenant until sign-in completes. Everything else is authenticated and +// scoped to the caller's own tenant. +service AuthService { + // Session; pre-token, public. An empty issuer means auth is disabled. + rpc GetAuthConfig(GetAuthConfigRequest) returns (GetAuthConfigResponse); + rpc Login(LoginRequest) returns (LoginResponse); + rpc GetToken(GetTokenRequest) returns (GetTokenResponse); + rpc Logout(LogoutRequest) returns (LogoutResponse); + rpc Register(RegisterRequest) returns (RegisterResponse); + rpc AcceptInvite(AcceptInviteRequest) returns (AcceptInviteResponse); + + // Session; authenticated. The UI's sign-in check. + rpc GetSession(GetSessionRequest) returns (GetSessionResponse); + + // Members; authenticated tenant administration. + rpc ListMembers(ListMembersRequest) returns (ListMembersResponse); + rpc InviteMember(InviteMemberRequest) returns (InviteMemberResponse); + rpc SetMemberRole(SetMemberRoleRequest) returns (SetMemberRoleResponse); + rpc RemoveMember(RemoveMemberRequest) returns (RemoveMemberResponse); + + // Service accounts; authenticated tenant administration. Secrets are only + // returned by create and rotate and cannot be retrieved later. + rpc ListServiceAccounts(ListServiceAccountsRequest) returns (ListServiceAccountsResponse); + rpc CreateServiceAccount(CreateServiceAccountRequest) returns (CreateServiceAccountResponse); + rpc RotateServiceAccountSecret(RotateServiceAccountSecretRequest) returns (RotateServiceAccountSecretResponse); + rpc RemoveServiceAccount(RemoveServiceAccountRequest) returns (RemoveServiceAccountResponse); +} diff --git a/identity/proto/auth/v1/service_accounts.proto b/identity/proto/auth/v1/service_accounts.proto new file mode 100644 index 0000000..b12db5d --- /dev/null +++ b/identity/proto/auth/v1/service_accounts.proto @@ -0,0 +1,52 @@ +syntax = "proto3"; + +package auth.v1; + +import "auth/v1/members.proto"; + +// ServiceAccount is a non-human identity used by automation such as the +// app's CLI. Its client secret is never returned by list operations. +message ServiceAccount { + string user_id = 1; + string client_id = 2; + string name = 3; + string description = 4; + Role role = 5; +} + +message ListServiceAccountsRequest {} + +message ListServiceAccountsResponse { + repeated ServiceAccount service_accounts = 1; + bool can_manage = 2; +} + +message CreateServiceAccountRequest { + string name = 1; + string description = 2; + Role role = 3; +} + +// CreateServiceAccountResponse returns client_secret exactly once. The app +// does not persist it and Zitadel cannot retrieve it again. +message CreateServiceAccountResponse { + ServiceAccount service_account = 1; + string client_secret = 2; +} + +message RotateServiceAccountSecretRequest { + string user_id = 1; +} + +// RotateServiceAccountSecretResponse returns the replacement secret exactly +// once. The previous secret is invalid after rotation. +message RotateServiceAccountSecretResponse { + string client_id = 1; + string client_secret = 2; +} + +message RemoveServiceAccountRequest { + string user_id = 1; +} + +message RemoveServiceAccountResponse {} diff --git a/identity/proto/auth/v1/session.proto b/identity/proto/auth/v1/session.proto new file mode 100644 index 0000000..2b73940 --- /dev/null +++ b/identity/proto/auth/v1/session.proto @@ -0,0 +1,83 @@ +syntax = "proto3"; + +package auth.v1; + +message GetAuthConfigRequest {} + +// GetAuthConfigResponse tells clients how to authenticate. An empty issuer +// means no provider is configured: the UI renders without a session and every +// other AuthService RPC is unimplemented. +message GetAuthConfigResponse { + string issuer = 1; + // service_account_scopes are requested by non-interactive clients. The + // provider owns these values so clients do not need provider-specific + // project IDs or reserved scope knowledge. + repeated string service_account_scopes = 2; + // invite_only means sign-up is closed: Register is refused and the UI + // offers no way to create an organization. People join by invitation. + bool invite_only = 3; +} + +// LoginRequest carries the credentials the app's own login page collected. +// A successful login answers with an HttpOnly session cookie that every later +// RPC carries; nothing about the provider reaches the browser. +message LoginRequest { + string login_name = 1; + string password = 2; +} + +message LoginResponse {} + +// GetTokenRequest exchanges a service account's credentials for an access +// token. The server runs the grant against the provider, so SDKs and the +// CLI need no provider knowledge and no route to it. +message GetTokenRequest { + string client_id = 1; + string client_secret = 2; +} + +// GetTokenResponse carries a bearer token and its lifetime in seconds. +// Clients mint again as it nears expiry. +message GetTokenResponse { + string access_token = 1; + int32 expires_in = 2; +} + +// LogoutRequest ends the session and clears its cookie. +message LogoutRequest {} + +message LogoutResponse {} + +// GetSessionRequest resolves the caller behind the session cookie. It is the +// UI's sign-in check: unauthenticated means show the login page. +message GetSessionRequest {} + +message GetSessionResponse { + string user_id = 1; + string name = 2; + string email = 3; + string avatar_url = 4; +} + +// RegisterRequest creates a tenant and its first admin. +message RegisterRequest { + string org_name = 1; + string given_name = 2; + string family_name = 3; + string email = 4; + string password = 5; +} + +message RegisterResponse { + string tenant_id = 1; +} + +// AcceptInviteRequest redeems an invite and sets the new member's password. +// The code is the credential, which is why this RPC is public. +message AcceptInviteRequest { + string user_id = 1; + string code = 2; + string password = 3; +} + +message AcceptInviteResponse {} diff --git a/identity/proxy/proxy.go b/identity/proxy/proxy.go new file mode 100644 index 0000000..4c75c1d --- /dev/null +++ b/identity/proxy/proxy.go @@ -0,0 +1,62 @@ +// Package proxy trusts identity headers set by an authenticating gateway in +// front of an app. It verifies nothing itself: the gateway has already +// authenticated the person, and the deployment's network policy is what +// makes the headers trustworthy. Sign-in, members and service accounts live +// upstream, so this is an Authenticator and not a Provider. +package proxy + +import ( + "context" + "errors" + "net/http" + + "connectrpc.com/connect" + + "github.com/galaxy-io/modules/identity" +) + +// Headers the gateway sets. The gateway strips any copy a client sent. +const ( + // UserHeader carries the gateway's stable user id. + UserHeader = "X-Forwarded-User" + // TenantHeader carries the gateway's tenant id. + TenantHeader = "X-Forwarded-Tenant" + // ServiceHeader names a trusted service calling on its own behalf. A + // request carrying it needs no user header. + ServiceHeader = "X-Forwarded-Service" +) + +// Authenticator reads callers off trusted gateway headers. +type Authenticator struct { + tenant string +} + +// New returns an Authenticator. A non-empty tenant pins the deployment to +// that gateway tenant and refuses any other; empty serves whatever tenant +// the gateway names. +func New(tenant string) *Authenticator { + return &Authenticator{tenant: tenant} +} + +// Authenticate resolves the caller from the gateway headers. A missing +// tenant or user is Unauthenticated; a tenant other than the pinned one is +// PermissionDenied. +func (a *Authenticator) Authenticate(_ context.Context, header http.Header) (identity.Caller, error) { + tenant := header.Get(TenantHeader) + if tenant == "" { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("no tenant header")) + } + if a.tenant != "" && tenant != a.tenant { + return identity.Caller{}, connect.NewError(connect.CodePermissionDenied, errors.New("tenant is not served here")) + } + if service := header.Get(ServiceHeader); service != "" { + return identity.Caller{TenantExternalID: tenant, Service: service}, nil + } + user := header.Get(UserHeader) + if user == "" { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("no user header")) + } + return identity.Caller{UserID: user, TenantExternalID: tenant}, nil +} + +var _ identity.Authenticator = (*Authenticator)(nil) diff --git a/identity/proxy/proxy_test.go b/identity/proxy/proxy_test.go new file mode 100644 index 0000000..8040dec --- /dev/null +++ b/identity/proxy/proxy_test.go @@ -0,0 +1,70 @@ +package proxy + +import ( + "context" + "net/http" + "testing" + + "connectrpc.com/connect" +) + +func TestAuthenticateUnpinned(t *testing.T) { + caller, err := New("").Authenticate(context.Background(), http.Header{TenantHeader: {"tenant-2"}, UserHeader: {"u1"}}) + if err != nil { + t.Fatal(err) + } + if caller.TenantExternalID != "tenant-2" || caller.UserID != "u1" { + t.Fatalf("unexpected caller %+v", caller) + } +} + +func TestAuthenticate(t *testing.T) { + a := New("tenant-1") + for name, tc := range map[string]struct { + header http.Header + code connect.Code + user string + service string + }{ + "no tenant header": { + header: http.Header{UserHeader: {"u1"}}, + code: connect.CodeUnauthenticated, + }, + "wrong tenant": { + header: http.Header{TenantHeader: {"tenant-2"}, UserHeader: {"u1"}}, + code: connect.CodePermissionDenied, + }, + "no user header": { + header: http.Header{TenantHeader: {"tenant-1"}}, + code: connect.CodeUnauthenticated, + }, + "user caller": { + header: http.Header{TenantHeader: {"tenant-1"}, UserHeader: {"u1"}}, + user: "u1", + }, + "service caller": { + header: http.Header{TenantHeader: {"tenant-1"}, ServiceHeader: {"operator"}}, + service: "operator", + }, + "service wins over user": { + header: http.Header{TenantHeader: {"tenant-1"}, UserHeader: {"u1"}, ServiceHeader: {"operator"}}, + service: "operator", + }, + } { + t.Run(name, func(t *testing.T) { + caller, err := a.Authenticate(context.Background(), tc.header) + if tc.code != 0 { + if connect.CodeOf(err) != tc.code { + t.Fatalf("got %v, want code %v", err, tc.code) + } + return + } + if err != nil { + t.Fatal(err) + } + if caller.TenantExternalID != "tenant-1" || caller.UserID != tc.user || caller.Service != tc.service { + t.Fatalf("unexpected caller %+v", caller) + } + }) + } +} diff --git a/identity/zitadel/bootstrap.go b/identity/zitadel/bootstrap.go new file mode 100644 index 0000000..dc8d4ed --- /dev/null +++ b/identity/zitadel/bootstrap.go @@ -0,0 +1,226 @@ +package zitadel + +import ( + "context" + "fmt" + + "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/auth" + authorizationv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/authorization/v2" + objectv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/object/v2" + orgv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/org/v2" + projectv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/project/v2" + userv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/user/v2" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// projectRef is the app's project and the organization owning it. Other +// organizations need the project granted to them before their users can +// hold the app roles. +type projectRef struct { + id string + ownerOrgID string +} + +// bootstrap converges the instance on the configuration the app needs and +// caches the project on the provider. Every step is idempotent, so each boot +// lands on the same state. +func (p *Provider) bootstrap(ctx context.Context) error { + // The machine user's own organization owns the app's project. + me, err := p.api.AuthService().GetMyUser(ctx, &auth.GetMyUserRequest{}) + if err != nil { + return fmt.Errorf("resolve machine user: %w", err) + } + if err := p.ensureProject(ctx, me.GetUser().GetDetails().GetResourceOwner()); err != nil { + return err + } + return p.ensureProjectRoles(ctx) +} + +// ensureProject finds or creates the app's project. Role assertion puts +// granted roles in every token issued for it, which is how service accounts +// carry theirs. +func (p *Provider) ensureProject(ctx context.Context, homeOrgID string) error { + found, err := p.api.ProjectServiceV2().ListProjects(ctx, &projectv2.ListProjectsRequest{ + Filters: []*projectv2.ProjectSearchFilter{{ + Filter: &projectv2.ProjectSearchFilter_ProjectNameFilter{ + ProjectNameFilter: &projectv2.ProjectNameFilter{ProjectName: p.app}, + }, + }}, + }) + if err != nil { + return fmt.Errorf("list projects: %w", err) + } + if result := found.GetProjects(); len(result) > 0 { + p.project = projectRef{id: result[0].GetProjectId(), ownerOrgID: result[0].GetOrganizationId()} + return nil + } + created, err := p.api.ProjectServiceV2().CreateProject(ctx, &projectv2.CreateProjectRequest{ + OrganizationId: homeOrgID, + Name: p.app, + ProjectRoleAssertion: true, + }) + if err != nil { + return fmt.Errorf("create project: %w", err) + } + p.project = projectRef{id: created.GetProjectId(), ownerOrgID: homeOrgID} + return nil +} + +// ensureProjectRoles adds any app role the project is missing. +func (p *Provider) ensureProjectRoles(ctx context.Context) error { + existing, err := p.api.ProjectServiceV2().ListProjectRoles(ctx, &projectv2.ListProjectRolesRequest{ + ProjectId: p.project.id, + }) + if err != nil { + return fmt.Errorf("list project roles: %w", err) + } + have := make(map[string]bool, len(existing.GetProjectRoles())) + for _, role := range existing.GetProjectRoles() { + have[role.GetKey()] = true + } + for _, key := range identity.RoleKeys() { + if have[key] { + continue + } + if _, err := p.api.ProjectServiceV2().AddProjectRole(ctx, &projectv2.AddProjectRoleRequest{ + ProjectId: p.project.id, + RoleKey: key, + DisplayName: key, + }); err != nil { + return fmt.Errorf("add project role %s: %w", key, err) + } + } + return nil +} + +// ensureProjectGrant shares the app's project with an organization once, so +// its users can hold the app roles. The owning organization needs none. +func (p *Provider) ensureProjectGrant(ctx context.Context, orgID string) error { + if orgID == p.project.ownerOrgID { + return nil + } + if _, granted := p.projectGrants.Load(orgID); granted { + return nil + } + _, err := p.api.ProjectServiceV2().CreateProjectGrant(ctx, &projectv2.CreateProjectGrantRequest{ + ProjectId: p.project.id, + GrantedOrganizationId: orgID, + RoleKeys: identity.RoleKeys(), + }) + // An existing grant is the desired state, not a failure; a later + // authorization call surfaces anything genuinely broken. + p.projectGrants.Store(orgID, struct{}{}) + if err != nil && !isAlreadyExists(err) { + return err + } + return nil +} + +// grantRole gives a user one app role in their organization. +func (p *Provider) grantRole(ctx context.Context, orgID, userID, roleKey string) error { + if err := p.ensureProjectGrant(ctx, orgID); err != nil { + return err + } + _, err := p.api.AuthorizationServiceV2().CreateAuthorization(ctx, &authorizationv2.CreateAuthorizationRequest{ + UserId: userID, + ProjectId: p.project.id, + OrganizationId: orgID, + RoleKeys: []string{roleKey}, + }) + return err +} + +// isAlreadyExists reports whether an error means the resource the app was +// about to create is already there, which is the state it wanted anyway. +func isAlreadyExists(err error) bool { + return status.Code(err) == codes.AlreadyExists +} + +// isUnavailable reports whether an error means Zitadel could not be reached +// at all, which must never be mistaken for a rejected credential. +func isUnavailable(err error) bool { + switch status.Code(err) { + case codes.Unavailable, codes.DeadlineExceeded: + return true + } + return false +} + +// bootstrapTenant converges the tenant and the person invited to administer +// it. Nothing changes when it is all already there. +func (p *Provider) bootstrapTenant(ctx context.Context, b Bootstrap) error { + found, err := p.api.OrganizationServiceV2().ListOrganizations(ctx, &orgv2.ListOrganizationsRequest{ + Queries: []*orgv2.SearchQuery{{ + Query: &orgv2.SearchQuery_NameQuery{ + NameQuery: &orgv2.OrganizationNameQuery{Name: b.Tenant, Method: objectv2.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}, + }, + }}, + }) + if err != nil { + return fmt.Errorf("list organizations: %w", err) + } + var orgID string + if result := found.GetResult(); len(result) > 0 { + orgID = result[0].GetId() + } else { + created, err := p.api.OrganizationServiceV2().AddOrganization(ctx, &orgv2.AddOrganizationRequest{Name: b.Tenant}) + if err != nil { + return fmt.Errorf("create organization: %w", err) + } + orgID = created.GetOrganizationId() + } + return p.bootstrapAdmin(ctx, orgID, b) +} + +// bootstrapAdmin converges the person who administers the tenant with the +// password the deployer chose. A missing user is created signed-in-ready +// with the Admin role. An existing one is left alone: the members page owns +// the role from then on, and a password changed since is kept. +// +// Zitadel requires a name on every human; the tenant supplies one until the +// admin is known by a better one. +func (p *Provider) bootstrapAdmin(ctx context.Context, orgID string, b Bootstrap) error { + users, err := p.api.UserServiceV2().ListUsers(ctx, &userv2.ListUsersRequest{ + Queries: []*userv2.SearchQuery{ + {Query: &userv2.SearchQuery_OrganizationIdQuery{ + OrganizationIdQuery: &userv2.OrganizationIdQuery{OrganizationId: orgID}, + }}, + {Query: &userv2.SearchQuery_EmailQuery{ + EmailQuery: &userv2.EmailQuery{EmailAddress: b.AdminEmail, Method: objectv2.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}, + }}, + }, + }) + if err != nil { + return fmt.Errorf("resolve admin: %w", err) + } + if len(users.GetResult()) > 0 { + return nil + } + created, err := p.api.UserServiceV2().CreateUser(ctx, &userv2.CreateUserRequest{ + OrganizationId: orgID, + UserType: &userv2.CreateUserRequest_Human_{ + Human: &userv2.CreateUserRequest_Human{ + Profile: &userv2.SetHumanProfile{GivenName: b.Tenant, FamilyName: "Admin"}, + Email: &userv2.SetHumanEmail{ + Email: b.AdminEmail, + Verification: &userv2.SetHumanEmail_IsVerified{IsVerified: true}, + }, + PasswordType: &userv2.CreateUserRequest_Human_Password{ + Password: &userv2.Password{Password: b.AdminPassword}, + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("create admin: %w", err) + } + if err := p.grantRole(ctx, orgID, created.GetId(), identity.RoleKey(authv1.Role_ROLE_ADMIN)); err != nil { + return fmt.Errorf("grant admin: %w", p.discardUser(ctx, created.GetId(), err)) + } + return nil +} diff --git a/identity/zitadel/members.go b/identity/zitadel/members.go new file mode 100644 index 0000000..0e24d00 --- /dev/null +++ b/identity/zitadel/members.go @@ -0,0 +1,228 @@ +package zitadel + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "connectrpc.com/connect" + authorizationv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/authorization/v2" + filterv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/filter/v2" + userv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/user/v2" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// ListMembers returns the tenant's users joined with the app role each +// holds. Both reads are scoped to the caller's own organization. +func (p *Provider) ListMembers(ctx context.Context, _ *connect.Request[authv1.ListMembersRequest]) (*connect.Response[authv1.ListMembersResponse], error) { + caller, ok := identity.CallerFrom(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + orgID := caller.TenantExternalID + + users, err := p.api.UserServiceV2().ListUsers(ctx, &userv2.ListUsersRequest{ + Queries: []*userv2.SearchQuery{ + {Query: &userv2.SearchQuery_OrganizationIdQuery{ + OrganizationIdQuery: &userv2.OrganizationIdQuery{OrganizationId: orgID}, + }}, + {Query: &userv2.SearchQuery_TypeQuery{ + TypeQuery: &userv2.TypeQuery{Type: userv2.Type_TYPE_HUMAN}, + }}, + }, + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + roles, err := p.rolesByUser(ctx, orgID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + members := make([]*authv1.Member, 0, len(users.GetResult())) + for _, user := range users.GetResult() { + human := user.GetHuman() + name := strings.TrimSpace(human.GetProfile().GetGivenName() + " " + human.GetProfile().GetFamilyName()) + members = append(members, &authv1.Member{ + UserId: user.GetUserId(), + Name: name, + Email: human.GetEmail().GetEmail(), + Role: roles[user.GetUserId()], + }) + } + return connect.NewResponse(&authv1.ListMembersResponse{ + Members: members, + CanManage: caller.IsAdmin(), + }), nil +} + +// rolesByUser maps each user in the organization to the app role their +// authorization grants. +func (p *Provider) rolesByUser(ctx context.Context, orgID string) (map[string]authv1.Role, error) { + granted, err := p.api.AuthorizationServiceV2().ListAuthorizations(ctx, &authorizationv2.ListAuthorizationsRequest{ + Filters: []*authorizationv2.AuthorizationsSearchFilter{ + {Filter: &authorizationv2.AuthorizationsSearchFilter_ProjectId{ProjectId: &filterv2.IDFilter{Id: p.project.id}}}, + {Filter: &authorizationv2.AuthorizationsSearchFilter_OrganizationId{OrganizationId: &filterv2.IDFilter{Id: orgID}}}, + }, + }) + if err != nil { + return nil, err + } + roles := make(map[string]authv1.Role, len(granted.GetAuthorizations())) + for _, authorization := range granted.GetAuthorizations() { + if role := grantedRole(authorization); role != authv1.Role_ROLE_UNSPECIFIED { + roles[authorization.GetUser().GetId()] = role + } + } + return roles, nil +} + +// grantedRole is the app role an authorization carries, unspecified +// when none of its keys is one app recognizes. +func grantedRole(authorization *authorizationv2.Authorization) authv1.Role { + for _, key := range authorization.GetRoles() { + if role := identity.RoleFromKey(key.GetKey()); role != authv1.Role_ROLE_UNSPECIFIED { + return role + } + } + return authv1.Role_ROLE_UNSPECIFIED +} + +// InviteMember creates a teammate in the caller's tenant and returns the +// code that redeems their invitation. Nothing is emailed; the admin shares +// the link. +func (p *Provider) InviteMember(ctx context.Context, req *connect.Request[authv1.InviteMemberRequest]) (*connect.Response[authv1.InviteMemberResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + m := req.Msg + if m.GetEmail() == "" || m.GetGivenName() == "" || m.GetFamilyName() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("email, given_name, and family_name are required")) + } + roleKey := identity.RoleKey(m.GetRole()) + if roleKey == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role is required")) + } + orgID := caller.TenantExternalID + + created, err := p.api.UserServiceV2().CreateUser(ctx, &userv2.CreateUserRequest{ + OrganizationId: orgID, + UserType: &userv2.CreateUserRequest_Human_{ + Human: &userv2.CreateUserRequest_Human{ + Profile: &userv2.SetHumanProfile{ + GivenName: m.GetGivenName(), + FamilyName: m.GetFamilyName(), + }, + Email: &userv2.SetHumanEmail{Email: m.GetEmail()}, + }, + }, + }) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + // returnCode hands the invitation back instead of mailing it. + invite, err := p.api.UserServiceV2().CreateInviteCode(ctx, &userv2.CreateInviteCodeRequest{ + UserId: created.GetId(), + Verification: &userv2.CreateInviteCodeRequest_ReturnCode{ReturnCode: &userv2.ReturnInviteCode{}}, + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, p.discardUser(ctx, created.GetId(), err)) + } + if err := p.grantRole(ctx, orgID, created.GetId(), roleKey); err != nil { + return nil, connect.NewError(connect.CodeInternal, p.discardUser(ctx, created.GetId(), err)) + } + return connect.NewResponse(&authv1.InviteMemberResponse{ + UserId: created.GetId(), + Code: invite.GetInviteCode(), + }), nil +} + +// discardUser deletes a user whose invitation did not complete, so the email +// is free for a retry rather than locked behind a half-created account. It +// runs detached from the request so a dropped client still triggers cleanup. +// cause is returned as-is on success; a failed delete is joined onto it, since +// the admin then has to remove the user by hand. +func (p *Provider) discardUser(ctx context.Context, userID string, cause error) error { + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if _, err := p.api.UserServiceV2().DeleteUser(ctx, &userv2.DeleteUserRequest{UserId: userID}); err != nil { + return errors.Join(cause, fmt.Errorf("cleanup of user %s failed, remove it manually: %w", userID, err)) + } + return cause +} + +// SetMemberRole reassigns a member's role, creating the authorization when +// the member has none yet. +func (p *Provider) SetMemberRole(ctx context.Context, req *connect.Request[authv1.SetMemberRoleRequest]) (*connect.Response[authv1.SetMemberRoleResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + m := req.Msg + if m.GetUserId() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + roleKey := identity.RoleKey(m.GetRole()) + if roleKey == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role is required")) + } + orgID := caller.TenantExternalID + + // Scoping the lookup to the caller's organization is what keeps one + // tenant from re-roling another's members. + existing, err := p.api.AuthorizationServiceV2().ListAuthorizations(ctx, &authorizationv2.ListAuthorizationsRequest{ + Filters: []*authorizationv2.AuthorizationsSearchFilter{ + {Filter: &authorizationv2.AuthorizationsSearchFilter_ProjectId{ProjectId: &filterv2.IDFilter{Id: p.project.id}}}, + {Filter: &authorizationv2.AuthorizationsSearchFilter_OrganizationId{OrganizationId: &filterv2.IDFilter{Id: orgID}}}, + {Filter: &authorizationv2.AuthorizationsSearchFilter_InUserIds{InUserIds: &filterv2.InIDsFilter{Ids: []string{m.GetUserId()}}}}, + }, + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + if found := existing.GetAuthorizations(); len(found) > 0 { + if _, err := p.api.AuthorizationServiceV2().UpdateAuthorization(ctx, &authorizationv2.UpdateAuthorizationRequest{ + Id: found[0].GetId(), + RoleKeys: []string{roleKey}, + }); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&authv1.SetMemberRoleResponse{}), nil + } + if err := p.grantRole(ctx, orgID, m.GetUserId(), roleKey); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&authv1.SetMemberRoleResponse{}), nil +} + +// RemoveMember deletes a member of the caller's tenant. Callers cannot +// remove themselves, and members of other tenants are unreachable. +func (p *Provider) RemoveMember(ctx context.Context, req *connect.Request[authv1.RemoveMemberRequest]) (*connect.Response[authv1.RemoveMemberResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + userID := req.Msg.GetUserId() + if userID == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + if userID == caller.UserID { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("you cannot remove yourself")) + } + target, err := p.api.UserServiceV2().GetUserByID(ctx, &userv2.GetUserByIDRequest{UserId: userID}) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("member not found")) + } + if target.GetUser().GetDetails().GetResourceOwner() != caller.TenantExternalID { + return nil, connect.NewError(connect.CodeNotFound, errors.New("member not found")) + } + if _, err := p.api.UserServiceV2().DeleteUser(ctx, &userv2.DeleteUserRequest{UserId: userID}); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&authv1.RemoveMemberResponse{}), nil +} diff --git a/identity/zitadel/service_accounts.go b/identity/zitadel/service_accounts.go new file mode 100644 index 0000000..53673cd --- /dev/null +++ b/identity/zitadel/service_accounts.go @@ -0,0 +1,238 @@ +package zitadel + +import ( + "context" + "errors" + "strings" + "time" + + "connectrpc.com/connect" + filterv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/filter/v2" + metadatav2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/metadata/v2" + userv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/user/v2" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// managedServiceAccountKey is the metadata key marking the machine users the +// app manages. +func (p *Provider) managedServiceAccountKey() string { return p.app + ".service_account" } + +// tokenTimeout bounds each grant against the token endpoint. +const tokenTimeout = 10 * time.Second + +// GetToken runs the client-credentials grant for a service account. Proxying +// it keeps the scopes and the issuer the server's business, so a client only +// ever holds its id and secret. +func (p *Provider) GetToken(ctx context.Context, req *connect.Request[authv1.GetTokenRequest]) (*connect.Response[authv1.GetTokenResponse], error) { + m := req.Msg + if m.GetClientId() == "" || m.GetClientSecret() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("client_id and client_secret are required")) + } + ctx, cancel := context.WithTimeout(ctx, tokenTimeout) + defer cancel() + config := clientcredentials.Config{ + ClientID: m.GetClientId(), + ClientSecret: m.GetClientSecret(), + TokenURL: p.tokenEndpoint, + Scopes: p.serviceAccountScopes(), + AuthStyle: oauth2.AuthStyleInHeader, + } + token, err := config.Token(ctx) + if err != nil { + var refused *oauth2.RetrieveError + if errors.As(err, &refused) { + // Unknown clients and wrong secrets both land here; neither + // should tell the caller which it was. + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid credentials")) + } + return nil, connect.NewError(connect.CodeUnavailable, err) + } + return connect.NewResponse(&authv1.GetTokenResponse{ + AccessToken: token.AccessToken, + ExpiresIn: int32(time.Until(token.Expiry).Seconds()), + }), nil +} + +// ListServiceAccounts returns the machine users in the caller's organization. +// Client secrets are never readable after creation and are not part of the +// response. +func (p *Provider) ListServiceAccounts(ctx context.Context, _ *connect.Request[authv1.ListServiceAccountsRequest]) (*connect.Response[authv1.ListServiceAccountsResponse], error) { + caller, ok := identity.CallerFrom(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + orgID := caller.TenantExternalID + users, err := p.api.UserServiceV2().ListUsers(ctx, &userv2.ListUsersRequest{ + Queries: p.managedServiceAccountQueries(orgID), + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + roles, err := p.rolesByUser(ctx, orgID) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + accounts := make([]*authv1.ServiceAccount, 0, len(users.GetResult())) + for _, user := range users.GetResult() { + machine := user.GetMachine() + accounts = append(accounts, &authv1.ServiceAccount{ + UserId: user.GetUserId(), + ClientId: user.GetUserId(), + Name: machine.GetName(), + Description: machine.GetDescription(), + Role: roles[user.GetUserId()], + }) + } + return connect.NewResponse(&authv1.ListServiceAccountsResponse{ + ServiceAccounts: accounts, + CanManage: caller.IsAdmin(), + }), nil +} + +// CreateServiceAccount creates a JWT machine user, grants its app role, +// and returns the client secret exactly once. +func (p *Provider) CreateServiceAccount(ctx context.Context, req *connect.Request[authv1.CreateServiceAccountRequest]) (*connect.Response[authv1.CreateServiceAccountResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + name := strings.TrimSpace(req.Msg.GetName()) + if name == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("name is required")) + } + roleKey := identity.RoleKey(req.Msg.GetRole()) + if roleKey == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("role is required")) + } + description := strings.TrimSpace(req.Msg.GetDescription()) + created, err := p.api.UserServiceV2().CreateUser(ctx, &userv2.CreateUserRequest{ + OrganizationId: caller.TenantExternalID, + Metadata: []*userv2.Metadata{{ + Key: p.managedServiceAccountKey(), + Value: []byte("true"), + }}, + UserType: &userv2.CreateUserRequest_Machine_{ + Machine: &userv2.CreateUserRequest_Machine{ + Name: name, + Description: &description, + AccessTokenType: userv2.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, + }, + }, + }) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + userID := created.GetId() + cleanup := func() { + _, _ = p.api.UserServiceV2().DeleteUser(ctx, &userv2.DeleteUserRequest{UserId: userID}) + } + secret, err := p.api.UserServiceV2().AddSecret(ctx, &userv2.AddSecretRequest{UserId: userID}) + if err != nil { + cleanup() + return nil, connect.NewError(connect.CodeInternal, err) + } + if err := p.grantRole(ctx, caller.TenantExternalID, userID, roleKey); err != nil { + cleanup() + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&authv1.CreateServiceAccountResponse{ + ServiceAccount: &authv1.ServiceAccount{ + UserId: userID, + ClientId: userID, + Name: name, + Description: description, + Role: req.Msg.GetRole(), + }, + ClientSecret: secret.GetClientSecret(), + }), nil +} + +// RotateServiceAccountSecret invalidates the current client secret and returns +// its replacement exactly once. +func (p *Provider) RotateServiceAccountSecret(ctx context.Context, req *connect.Request[authv1.RotateServiceAccountSecretRequest]) (*connect.Response[authv1.RotateServiceAccountSecretResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + userID := req.Msg.GetUserId() + if userID == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + if err := p.requireServiceAccount(ctx, caller.TenantExternalID, userID); err != nil { + return nil, err + } + if _, err := p.api.UserServiceV2().RemoveSecret(ctx, &userv2.RemoveSecretRequest{UserId: userID}); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + secret, err := p.api.UserServiceV2().AddSecret(ctx, &userv2.AddSecretRequest{UserId: userID}) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&authv1.RotateServiceAccountSecretResponse{ + ClientId: userID, + ClientSecret: secret.GetClientSecret(), + }), nil +} + +// RemoveServiceAccount deletes a machine user in the caller's organization. +func (p *Provider) RemoveServiceAccount(ctx context.Context, req *connect.Request[authv1.RemoveServiceAccountRequest]) (*connect.Response[authv1.RemoveServiceAccountResponse], error) { + caller, err := adminCaller(ctx) + if err != nil { + return nil, err + } + userID := req.Msg.GetUserId() + if userID == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id is required")) + } + if err := p.requireServiceAccount(ctx, caller.TenantExternalID, userID); err != nil { + return nil, err + } + if _, err := p.api.UserServiceV2().DeleteUser(ctx, &userv2.DeleteUserRequest{UserId: userID}); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&authv1.RemoveServiceAccountResponse{}), nil +} + +func (p *Provider) requireServiceAccount(ctx context.Context, orgID, userID string) error { + users, err := p.api.UserServiceV2().ListUsers(ctx, &userv2.ListUsersRequest{ + Queries: p.managedServiceAccountQueries(orgID, userID), + }) + if err != nil { + return connect.NewError(connect.CodeInternal, err) + } + if len(users.GetResult()) == 0 { + return connect.NewError(connect.CodeNotFound, errors.New("service account not found")) + } + return nil +} + +// managedServiceAccountQueries excludes Zitadel's own bootstrap/admin machine +// users. Only identities created by the app carry this marker and can be +// listed, rotated, or removed through the application. +func (p *Provider) managedServiceAccountQueries(orgID string, userIDs ...string) []*userv2.SearchQuery { + queries := []*userv2.SearchQuery{ + {Query: &userv2.SearchQuery_OrganizationIdQuery{ + OrganizationIdQuery: &userv2.OrganizationIdQuery{OrganizationId: orgID}, + }}, + {Query: &userv2.SearchQuery_TypeQuery{ + TypeQuery: &userv2.TypeQuery{Type: userv2.Type_TYPE_MACHINE}, + }}, + {Query: &userv2.SearchQuery_MetadataKeyFilter{ + MetadataKeyFilter: &metadatav2.MetadataKeyFilter{ + Key: p.managedServiceAccountKey(), + Method: filterv2.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + }}, + } + if len(userIDs) > 0 { + queries = append(queries, &userv2.SearchQuery{Query: &userv2.SearchQuery_InUserIdsQuery{ + InUserIdsQuery: &userv2.InUserIDQuery{UserIds: userIDs}, + }}) + } + return queries +} diff --git a/identity/zitadel/session.go b/identity/zitadel/session.go new file mode 100644 index 0000000..03bca69 --- /dev/null +++ b/identity/zitadel/session.go @@ -0,0 +1,303 @@ +package zitadel + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "connectrpc.com/connect" + authorizationv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/authorization/v2" + filterv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/filter/v2" + orgv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/org/v2" + sessionv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/session/v2" + userv2 "github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/user/v2" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// Login creates a Zitadel session and hands the browser its id and token in +// an HttpOnly cookie. That cookie is the credential every later RPC carries, +// so nothing about Zitadel reaches the browser. The cookie and the session +// share a lifetime, so both expire together. +const ( + sessionLifetime = 30 * 24 * time.Hour + // callerCacheTTL bounds how long a resolved session skips Zitadel; role + // changes and logouts from elsewhere take effect within it. + callerCacheTTL = time.Minute +) + +// sessionRef identifies a Zitadel session together with the token that +// authorizes acting on it. +type sessionRef struct { + id string + token string +} + +func (s sessionRef) String() string { return s.id + "." + s.token } + +// sessionCookie builds the session cookie; an empty value with a negative +// maxAge clears it. +func (p *Provider) sessionCookie(value string, maxAge int) *http.Cookie { + return &http.Cookie{ //nolint:gosec // Secure follows the UI origin's scheme so plain-http local dev keeps its cookie + Name: p.sessionCookieName(), + Value: value, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: p.secureCookies, + SameSite: http.SameSiteLaxMode, + } +} + +// sessionFromCookie reads the session off a request, reporting false when +// there is none. The token is required: the machine user could read any +// session by id alone, so a cookie without one must never be honored. +func (p *Provider) sessionFromCookie(header http.Header) (sessionRef, bool) { + cookie, err := (&http.Request{Header: header}).Cookie(p.sessionCookieName()) + if err != nil { + return sessionRef{}, false + } + id, token, ok := strings.Cut(cookie.Value, ".") + if !ok || id == "" || token == "" { + return sessionRef{}, false + } + return sessionRef{id: id, token: token}, true +} + +// cachedCaller is a resolved session that skips Zitadel until expires. +type cachedCaller struct { + caller identity.Caller + expires time.Time +} + +// authenticateSession resolves the caller behind a session cookie: Zitadel +// validates the token, the session names the user and organization, and an +// authorization lookup supplies the roles. +func (p *Provider) authenticateSession(ctx context.Context, ref sessionRef) (identity.Caller, error) { + now := time.Now() + if cached, ok := p.callers.Load(ref); ok && now.Before(cached.(cachedCaller).expires) { + return cached.(cachedCaller).caller, nil + } + found, err := p.api.SessionServiceV2().GetSession(ctx, &sessionv2.GetSessionRequest{ + SessionId: ref.id, + SessionToken: &ref.token, + }) + if err != nil { + if isUnavailable(err) { + return identity.Caller{}, connect.NewError(connect.CodeUnavailable, err) + } + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid session")) + } + session := found.GetSession() + if expiry := session.GetExpirationDate(); expiry != nil && !now.Before(expiry.AsTime()) { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("session expired")) + } + user := session.GetFactors().GetUser() + if user.GetId() == "" || user.GetOrganizationId() == "" { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("session carries no user")) + } + roles, err := p.userRoles(ctx, user.GetId()) + if err != nil { + if isUnavailable(err) { + return identity.Caller{}, connect.NewError(connect.CodeUnavailable, err) + } + return identity.Caller{}, connect.NewError(connect.CodeInternal, err) + } + caller := identity.Caller{ + UserID: user.GetId(), + TenantExternalID: user.GetOrganizationId(), + Roles: roles, + } + p.rememberCaller(ref, caller, now) + return caller, nil +} + +// rememberCaller caches a resolved session and drops any entries that have +// expired; logins are rare enough that the sweep costs nothing. +func (p *Provider) rememberCaller(ref sessionRef, caller identity.Caller, now time.Time) { + p.callers.Range(func(key, value any) bool { + if !now.Before(value.(cachedCaller).expires) { + p.callers.Delete(key) + } + return true + }) + p.callers.Store(ref, cachedCaller{caller: caller, expires: now.Add(callerCacheTTL)}) +} + +// userRoles returns the app roles granted to one user in the project. +func (p *Provider) userRoles(ctx context.Context, userID string) ([]authv1.Role, error) { + granted, err := p.api.AuthorizationServiceV2().ListAuthorizations(ctx, &authorizationv2.ListAuthorizationsRequest{ + Filters: []*authorizationv2.AuthorizationsSearchFilter{ + {Filter: &authorizationv2.AuthorizationsSearchFilter_ProjectId{ProjectId: &filterv2.IDFilter{Id: p.project.id}}}, + {Filter: &authorizationv2.AuthorizationsSearchFilter_InUserIds{InUserIds: &filterv2.InIDsFilter{Ids: []string{userID}}}}, + }, + }) + if err != nil { + return nil, err + } + var roles []authv1.Role + for _, authorization := range granted.GetAuthorizations() { + if role := grantedRole(authorization); role != authv1.Role_ROLE_UNSPECIFIED { + roles = append(roles, role) + } + } + return roles, nil +} + +// Login checks the credentials as a Zitadel session and answers with the +// cookie that carries it. Proxying keeps Zitadel off the browser's origin +// and the flow in one place. +func (p *Provider) Login(ctx context.Context, req *connect.Request[authv1.LoginRequest]) (*connect.Response[authv1.LoginResponse], error) { + m := req.Msg + if m.GetLoginName() == "" || m.GetPassword() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("login_name and password are required")) + } + + created, err := p.api.SessionServiceV2().CreateSession(ctx, &sessionv2.CreateSessionRequest{ + Checks: &sessionv2.Checks{ + User: &sessionv2.CheckUser{Search: &sessionv2.CheckUser_LoginName{LoginName: m.GetLoginName()}}, + Password: &sessionv2.CheckPassword{ + Password: m.GetPassword(), + }, + }, + Lifetime: durationpb.New(sessionLifetime), + }) + if err != nil { + if isUnavailable(err) { + return nil, connect.NewError(connect.CodeUnavailable, err) + } + // Unknown users and wrong passwords both land here; neither should + // tell the caller which it was. + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid credentials")) + } + + session := sessionRef{id: created.GetSessionId(), token: created.GetSessionToken()} + res := connect.NewResponse(&authv1.LoginResponse{}) + res.Header().Add("Set-Cookie", p.sessionCookie(session.String(), int(sessionLifetime.Seconds())).String()) + return res, nil +} + +// Logout ends the session and clears its cookie. Terminating the session in +// Zitadel is best effort: the cookie goes away regardless. +func (p *Provider) Logout(ctx context.Context, req *connect.Request[authv1.LogoutRequest]) (*connect.Response[authv1.LogoutResponse], error) { + if session, ok := p.sessionFromCookie(req.Header()); ok { + p.callers.Delete(session) + _, _ = p.api.SessionServiceV2().DeleteSession(ctx, &sessionv2.DeleteSessionRequest{ + SessionId: session.id, + SessionToken: &session.token, + }) + } + res := connect.NewResponse(&authv1.LogoutResponse{}) + res.Header().Add("Set-Cookie", p.sessionCookie("", -1).String()) + return res, nil +} + +// GetSession describes the caller behind the request for the UI's own +// account surfaces. Machine users have no human profile and answer with +// their id alone. +func (p *Provider) GetSession(ctx context.Context, _ *connect.Request[authv1.GetSessionRequest]) (*connect.Response[authv1.GetSessionResponse], error) { + caller, ok := identity.CallerFrom(ctx) + if !ok { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + found, err := p.api.UserServiceV2().GetUserByID(ctx, &userv2.GetUserByIDRequest{UserId: caller.UserID}) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + human := found.GetUser().GetHuman() + return connect.NewResponse(&authv1.GetSessionResponse{ + UserId: caller.UserID, + Name: human.GetProfile().GetDisplayName(), + Email: human.GetEmail().GetEmail(), + AvatarUrl: human.GetProfile().GetAvatarUrl(), + }), nil +} + +// Register creates an organization and its first admin: the app's +// self-service signup. The organization becomes the tenant. An invite-only +// deployment refuses. +func (p *Provider) Register(ctx context.Context, req *connect.Request[authv1.RegisterRequest]) (*connect.Response[authv1.RegisterResponse], error) { + if p.inviteOnly { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("sign-up is closed on this deployment; ask an admin for an invitation")) + } + m := req.Msg + if m.GetOrgName() == "" || m.GetGivenName() == "" || m.GetFamilyName() == "" || m.GetEmail() == "" || m.GetPassword() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("org_name, given_name, family_name, email, and password are required")) + } + + created, err := p.api.OrganizationServiceV2().AddOrganization(ctx, &orgv2.AddOrganizationRequest{ + Name: m.GetOrgName(), + Admins: []*orgv2.AddOrganizationRequest_Admin{{ + UserType: &orgv2.AddOrganizationRequest_Admin_Human{ + Human: &userv2.AddHumanUserRequest{ + Profile: &userv2.SetHumanProfile{ + GivenName: m.GetGivenName(), + FamilyName: m.GetFamilyName(), + }, + // The app sends no mail, so the founder's address + // starts verified and they can sign in immediately. + Email: &userv2.SetHumanEmail{ + Email: m.GetEmail(), + Verification: &userv2.SetHumanEmail_IsVerified{IsVerified: true}, + }, + PasswordType: &userv2.AddHumanUserRequest_Password{ + Password: &userv2.Password{Password: m.GetPassword()}, + }, + }, + }, + }}, + }) + if err != nil { + // Duplicate names and password-policy failures both land here with + // Zitadel's reason attached. + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + // The founder administers the tenant they just created. + orgID := created.GetOrganizationId() + adminRole := identity.RoleKey(authv1.Role_ROLE_ADMIN) + for _, admin := range created.GetCreatedAdmins() { + if err := p.grantRole(ctx, orgID, admin.GetUserId(), adminRole); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + } + return connect.NewResponse(&authv1.RegisterResponse{TenantId: orgID}), nil +} + +// AcceptInvite redeems an invitation and sets the member's password. The +// code is the credential, which is why this RPC is public. +func (p *Provider) AcceptInvite(ctx context.Context, req *connect.Request[authv1.AcceptInviteRequest]) (*connect.Response[authv1.AcceptInviteResponse], error) { + m := req.Msg + if m.GetUserId() == "" || m.GetCode() == "" || m.GetPassword() == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("user_id, code, and password are required")) + } + if _, err := p.api.UserServiceV2().VerifyInviteCode(ctx, &userv2.VerifyInviteCodeRequest{ + UserId: m.GetUserId(), + VerificationCode: m.GetCode(), + }); err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid or expired invite")) + } + if _, err := p.api.UserServiceV2().UpdateUser(ctx, &userv2.UpdateUserRequest{ + UserId: m.GetUserId(), + UserType: &userv2.UpdateUserRequest_Human_{ + Human: &userv2.UpdateUserRequest_Human{ + Password: &userv2.SetPassword{ + PasswordType: &userv2.SetPassword_Password{ + Password: &userv2.Password{Password: m.GetPassword(), ChangeRequired: false}, + }, + }, + }, + }, + }); err != nil { + // Password-policy failures surface with Zitadel's reason. + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + return connect.NewResponse(&authv1.AcceptInviteResponse{}), nil +} + +// sessionCookieName is the cookie the browser holds its session in. +func (p *Provider) sessionCookieName() string { return p.app + "_sid" } diff --git a/identity/zitadel/zitadel.go b/identity/zitadel/zitadel.go new file mode 100644 index 0000000..cac8fac --- /dev/null +++ b/identity/zitadel/zitadel.go @@ -0,0 +1,256 @@ +// Package zitadel adapts a self-hosted Zitadel instance to the app's +// identity port. The app owns every screen and the AuthService contract; +// Zitadel stores credentials, holds sessions, and mints the tokens service +// accounts use. The server is Zitadel's only client: browsers hold a session +// cookie and never learn the issuer. +// +// A bootstrap tenant is converged on boot with a person who administers it +// with a password the deployer chose, which closes sign-up: registration is +// refused and everyone else is invited. +// +// It is a separate module so its gRPC and SDK dependencies stay out of the +// core module every connector builds against. +package zitadel + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + + "connectrpc.com/connect" + oidcclient "github.com/zitadel/oidc/v3/pkg/client" + "github.com/zitadel/zitadel-go/v3/pkg/authorization" + zitadeloauth "github.com/zitadel/zitadel-go/v3/pkg/authorization/oauth" + zclient "github.com/zitadel/zitadel-go/v3/pkg/client" + "github.com/zitadel/zitadel-go/v3/pkg/zitadel" + + "github.com/galaxy-io/modules/identity" + authv1 "github.com/galaxy-io/modules/identity/auth/v1" +) + +// Zitadel puts the user's organization on the token when the client requests +// the resourceowner scope. The organization is the tenant. +const ( + claimOrgID = "urn:zitadel:iam:user:resourceowner:id" + claimOrgName = "urn:zitadel:iam:user:resourceowner:name" +) + +// Options configures a Provider. +type Options struct { + // App names the app the provider serves, such as "billing". It + // prefixes the session cookie (_sid), the attributes kept on service + // accounts (.service_account), and names the Zitadel project. + App string + // Issuer is the Zitadel base URL. + Issuer string + // PAT is a machine-user personal access token holding instance rights. + PAT string + // UIOrigin is the origin the UI is served from; https marks the session + // cookie Secure. + UIOrigin string + // Bootstrap, when set, is a tenant converged on every boot with the + // person who administers it. + Bootstrap Bootstrap +} + +// Bootstrap names a tenant and the person who administers it, with the +// password the deployer chose. All fields or none. +type Bootstrap struct { + Tenant string + AdminEmail string + AdminPassword string +} + +func (b Bootstrap) set() bool { return b.Tenant != "" || b.AdminEmail != "" || b.AdminPassword != "" } + +func (b Bootstrap) complete() bool { + return b.Tenant != "" && b.AdminEmail != "" && b.AdminPassword != "" +} + +// Provider implements the app's identity port against Zitadel. +type Provider struct { + app string + issuer string + // tokenEndpoint is discovered once; GetToken runs every grant against it. + tokenEndpoint string + api *zclient.Client + verifier authorization.Verifier[*zitadeloauth.IntrospectionContext] + project projectRef + // rolesClaim is precomputed; every token-authenticated request reads it. + rolesClaim string + // secureCookies marks the session cookie Secure when the UI is served + // over https. + secureCookies bool + // inviteOnly refuses registration: an admin was bootstrapped and people + // join by invitation. + inviteOnly bool + // callers caches sessionRef -> cachedCaller so a browser's requests do + // not each round-trip to Zitadel. + callers sync.Map + // projectGrants caches org id -> project grant id. + projectGrants sync.Map +} + +// New provisions the app's project in Zitadel and returns a provider ready +// to authenticate. Provisioning is idempotent: every boot converges the same +// instance state. +func New(ctx context.Context, opts Options) (*Provider, error) { + if opts.App == "" { + return nil, errors.New("zitadel: app is required") + } + if opts.Issuer == "" { + return nil, errors.New("zitadel: issuer is required") + } + if opts.PAT == "" { + return nil, errors.New("zitadel: personal access token is required") + } + if opts.Bootstrap.set() && !opts.Bootstrap.complete() { + return nil, errors.New("zitadel: bootstrap needs a tenant, an admin email, and an admin password") + } + issuer := strings.TrimRight(opts.Issuer, "/") + target, err := dialTarget(issuer) + if err != nil { + return nil, err + } + api, err := zclient.New(ctx, target, zclient.WithAuth(zclient.PAT(strings.TrimSpace(opts.PAT)))) + if err != nil { + return nil, fmt.Errorf("zitadel: connect %s: %w", issuer, err) + } + + discovered, err := oidcclient.Discover(ctx, issuer, http.DefaultClient) + if err != nil { + _ = api.Close() + return nil, fmt.Errorf("zitadel: discover %s: %w", issuer, err) + } + p := &Provider{ + app: opts.App, + issuer: issuer, + tokenEndpoint: discovered.TokenEndpoint, + api: api, + secureCookies: strings.HasPrefix(opts.UIOrigin, "https://"), + inviteOnly: opts.Bootstrap.AdminEmail != "", + } + if err := p.bootstrap(ctx); err != nil { + _ = api.Close() + return nil, fmt.Errorf("zitadel bootstrap: %w", err) + } + if opts.Bootstrap.set() { + if err := p.bootstrapTenant(ctx, opts.Bootstrap); err != nil { + _ = api.Close() + return nil, fmt.Errorf("zitadel bootstrap tenant %q: %w", opts.Bootstrap.Tenant, err) + } + } + verifier, err := zitadeloauth.WithJWT(p.project.id, http.DefaultClient)(ctx, target) + if err != nil { + _ = api.Close() + return nil, fmt.Errorf("initialize access-token verifier for %s: %w", issuer, err) + } + p.verifier = verifier + p.rolesClaim = "urn:zitadel:iam:org:project:" + p.project.id + ":roles" + return p, nil +} + +// dialTarget turns the issuer URL into the SDK's host description, keeping +// plaintext local instances working. +func dialTarget(issuer string) (*zitadel.Zitadel, error) { + u, err := url.Parse(issuer) + if err != nil { + return nil, fmt.Errorf("zitadel: parse issuer %q: %w", issuer, err) + } + if u.Scheme == "https" { + return zitadel.New(u.Hostname()), nil + } + port := u.Port() + if port == "" { + port = "80" + } + return zitadel.New(u.Hostname(), zitadel.WithInsecure(port)), nil +} + +// Close releases the provider's connection to Zitadel. +func (p *Provider) Close() error { return p.api.Close() } + +// Authenticate resolves the caller behind a request. Service accounts carry +// a bearer access token verified against the issuer's JWKS; browsers carry +// the session cookie Login set. +func (p *Provider) Authenticate(ctx context.Context, header http.Header) (identity.Caller, error) { + if bearer, ok := strings.CutPrefix(header.Get("Authorization"), "Bearer "); ok && bearer != "" { + return p.authenticateToken(ctx, bearer) + } + if session, ok := p.sessionFromCookie(header); ok { + return p.authenticateSession(ctx, session) + } + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("no credentials")) +} + +// authenticateToken verifies an access token and resolves the caller's +// tenant and roles from its claims. +func (p *Provider) authenticateToken(ctx context.Context, bearer string) (identity.Caller, error) { + token, err := p.verifier.CheckAuthorization(ctx, "Bearer "+bearer) + if err != nil { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, err) + } + claims := token.Claims + orgID, _ := claims[claimOrgID].(string) + if orgID == "" { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("token carries no organization; request the resourceowner scope")) + } + orgName, _ := claims[claimOrgName].(string) + userID, _ := claims["sub"].(string) + + caller := identity.Caller{ + UserID: userID, + TenantExternalID: orgID, + TenantName: orgName, + } + if granted, ok := claims[p.rolesClaim].(map[string]any); ok { + caller.Roles = make([]authv1.Role, 0, len(granted)) + for key := range granted { + if role := identity.RoleFromKey(key); role != authv1.Role_ROLE_UNSPECIFIED { + caller.Roles = append(caller.Roles, role) + } + } + } + if len(caller.Roles) == 0 { + return identity.Caller{}, connect.NewError(connect.CodePermissionDenied, errors.New("token carries no "+p.app+" role")) + } + return caller, nil +} + +// GetAuthConfig tells the UI that auth is on and non-interactive clients +// what to request from the issuer. +func (p *Provider) GetAuthConfig(_ context.Context, _ *connect.Request[authv1.GetAuthConfigRequest]) (*connect.Response[authv1.GetAuthConfigResponse], error) { + return connect.NewResponse(&authv1.GetAuthConfigResponse{ + Issuer: p.issuer, + ServiceAccountScopes: p.serviceAccountScopes(), + InviteOnly: p.inviteOnly, + }), nil +} + +// serviceAccountScopes put the organization, the app's audience, and the +// caller's roles on a client-credentials token. +func (p *Provider) serviceAccountScopes() []string { + return []string{ + "openid", + "urn:zitadel:iam:user:resourceowner", + "urn:zitadel:iam:org:project:id:" + p.project.id + ":aud", + "urn:zitadel:iam:org:projects:roles", + } +} + +// adminCaller resolves the caller the interceptor stashed and requires +// tenant administration. +func adminCaller(ctx context.Context) (identity.Caller, error) { + c, ok := identity.CallerFrom(ctx) + if !ok { + return identity.Caller{}, connect.NewError(connect.CodeUnauthenticated, errors.New("unauthenticated")) + } + if !c.IsAdmin() { + return identity.Caller{}, connect.NewError(connect.CodePermissionDenied, errors.New("only admins can manage the team")) + } + return c, nil +} diff --git a/release-please-config.json b/release-please-config.json index 1214952..4b801e5 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -5,6 +5,9 @@ "release-type": "go", "tag-separator": "/", "packages": { + "identity": { + "component": "identity" + }, "secret": { "component": "secret" }