Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import (
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"

"github.com/google/uuid"

"accounts/pkg/auth"
"accounts/pkg/business"
policyv1 "accounts/pkg/gen/saas/policy/v1"
Expand Down Expand Up @@ -455,12 +453,19 @@ func stampForwardedGRPCIdentity(ctx context.Context, md metadata.MD) (context.Co
func stampRequestIdentity(ctx context.Context, identity auth.RequestIdentity, assurance auth.Assurance) context.Context {
subject := identity.EffectiveSubjectID()
orgID := identity.OrgID.String()
// WithVerifiedDatabaseIdentity binds wool's organization as well as the
// database scope, so every verified door carries it — a delegated read, a
// module parent and the work-context authority all install a verified
// identity without passing through this transport.
//
// The user id is still stamped HERE, unconditionally, because the two have
// different availability: database scope needs BOTH uuids and that function
// binds nothing without them, while a verified user who has not selected an
// organization yet is an ordinary request whose logs and rate-limit key
// should still name them.
ctx = auth.WithVerifiedDatabaseIdentity(ctx, subject, orgID)
ctx = context.WithValue(ctx, wool.UserIDKey, subject)
ctx = context.WithValue(ctx, wool.UserAuthIDKey, subject)
if identity.OrgID != uuid.Nil {
ctx = context.WithValue(ctx, wool.OrgIDKey, orgID)
}
ctx = auth.WithVerifiedRequestIdentity(ctx, identity)
ctx = auth.WithVerifiedActor(ctx, identity.Delegation)
ctx = auth.WithVerifiedSessionID(ctx, identity.SessionID)
Expand Down
21 changes: 21 additions & 0 deletions module/services/accounts/code/pkg/auth/database_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"errors"

"github.com/codefly-dev/core/wool"

"github.com/google/uuid"
)

Expand Down Expand Up @@ -37,6 +39,25 @@ func WithVerifiedDatabaseIdentity(ctx context.Context, userID, tenantID string)
if ctx == nil || userErr != nil || tenantErr != nil || user == uuid.Nil || tenant == uuid.Nil {
return ctx
}
// Propagate the same pair onto wool's identity dimension, so everything
// downstream — logs, traces, the rate-limit key, an organization-defaulted
// read — sees whose request this is without being handed it.
//
// Set as context values rather than through wool.Wool.WithOrgID, which
// cannot work: `with` assigns to the Wool's OWN ctx field, and wool.Get
// returns a fresh Wool per call, so the derived context is discarded and no
// later wool.Get(ctx).OrgID() can see it. The keys are exported and
// wool.Wool.lookup reads them off the context, so this is the propagation
// the accessors were written for.
//
// It is bound HERE and nowhere else for the same reason the verified
// identity is: this is the one door, it has already parsed both UUIDs, and
// the invalid path returned above — so wool's organization can only ever
// carry a verified value. That matters now that it is not merely an
// observability field: a key service selects an organization's envelope key
// by it.
ctx = context.WithValue(ctx, wool.OrgIDKey, tenant.String())
ctx = context.WithValue(ctx, wool.UserIDKey, user.String())
return context.WithValue(ctx, verifiedDatabaseIdentityKey{}, verifiedDatabaseIdentity{
tenantID: tenant.String(),
userID: user.String(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package auth_test

import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"sort"
"strings"
"testing"

"github.com/stretchr/testify/require"
)

// sourceSitesSettingWoolIdentity returns every non-test file under the service
// that writes wool's identity keys, as a repository-relative path.
//
// Derived from the syntax tree rather than grepped: a grep for the key name also
// matches a read, and the question here is specifically who WRITES.
func sourceSitesSettingWoolIdentity(t *testing.T) []string {
t.Helper()
identityKeys := map[string]bool{"OrgIDKey": true, "UserIDKey": true}
found := map[string]bool{}

root := "../.."
require.NoError(t, filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
switch entry.Name() {
case "node_modules", "gen", "generated", "testdata":
return fs.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
file, parseErr := parser.ParseFile(token.NewFileSet(), path, nil, 0)
if parseErr != nil {
return nil
}
ast.Inspect(file, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
// context.WithValue(ctx, wool.OrgIDKey, …)
if selector, ok := call.Fun.(*ast.SelectorExpr); ok && selector.Sel.Name == "WithValue" && len(call.Args) == 3 {
if key, ok := call.Args[1].(*ast.SelectorExpr); ok && identityKeys[key.Sel.Name] {
if pkg, ok := key.X.(*ast.Ident); ok && pkg.Name == "wool" {
found[filepath.ToSlash(filepath.Clean(path))] = true
}
}
}
// w.WithOrgID(…) / w.WithUserID(…) — a no-op as the API is built,
// but a writer in intent, so it counts.
if selector, ok := call.Fun.(*ast.SelectorExpr); ok {
if selector.Sel.Name == "WithOrgID" || selector.Sel.Name == "WithUserID" {
found[filepath.ToSlash(filepath.Clean(path))] = true
}
}
return true
})
return nil
}))

sites := make([]string, 0, len(found))
for path := range found {
sites = append(sites, strings.TrimPrefix(path, "../../"))
}
sort.Strings(sites)
return sites
}
84 changes: 84 additions & 0 deletions module/services/accounts/code/pkg/auth/wool_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package auth_test

import (
"context"
"testing"

"github.com/codefly-dev/core/wool"
"github.com/google/uuid"
"github.com/stretchr/testify/require"

"accounts/pkg/auth"
)

// The verified door is the one place wool's identity is bound, so everything
// downstream — logs, traces, the rate-limit key, an organization-defaulted read
// — sees whose request this is without being handed it.
func TestVerifiedDatabaseIdentityPopulatesWool(t *testing.T) {
user, org := uuid.NewString(), uuid.NewString()

ctx := auth.WithVerifiedDatabaseIdentity(context.Background(), user, org)

gotOrg, ok := wool.Get(ctx).OrgID()
require.True(t, ok, "wool must carry the organization after the verified door")
require.Equal(t, org, gotOrg)
gotUser, ok := wool.Get(ctx).UserID()
require.True(t, ok)
require.Equal(t, user, gotUser)
}

// The invalid path returns the context untouched, so a malformed
// trusted-forwarding value cannot put an organization into wool either — the
// same fail-closed behaviour the verified identity already had.
func TestAMalformedIdentityPopulatesNothing(t *testing.T) {
for name, identity := range map[string][2]string{
"unparseable org": {uuid.NewString(), "not-a-uuid"},
"unparseable user": {"not-a-uuid", uuid.NewString()},
"nil org": {uuid.NewString(), uuid.Nil.String()},
"nil user": {uuid.Nil.String(), uuid.NewString()},
"both empty": {"", ""},
} {
t.Run(name, func(t *testing.T) {
ctx := auth.WithVerifiedDatabaseIdentity(context.Background(), identity[0], identity[1])

_, ok := wool.Get(ctx).OrgID()
require.False(t, ok, "a malformed identity must leave wool empty")
_, _, verified := auth.VerifiedDatabaseIdentity(ctx)
require.False(t, verified)
})
}
}

// wool's organization is now read by things that matter (the rate-limit key, an
// organization-defaulted audit read), so it must only ever carry a verified
// value. This pins that the verified door is the ONLY writer: the key is
// exported, so anything may set it, and a second writer — especially one fed by
// caller-controlled input — would make wool's organization forgeable.
//
// It is deliberately NOT an authority source even so: pkg/auth states that
// transport headers and wool values are insufficient database or
// authorization-cache authority, and this does not change that. The point of one
// writer is that the observability dimension cannot be made to lie.
func TestOnlyTheVerifiedDoorWritesWoolIdentity(t *testing.T) {
// Two writers, and each one's reason is why the list is pinned rather than
// merely bounded:
//
// database_identity.go — the verified door. Binds the organization (and
// the user) on every path that installs a verified identity, which is
// the only way wool's organization can be trusted at all.
// grpc_auth_interceptor.go — the user id only, unconditionally, because a
// verified user who has not selected an organization binds no database
// scope and so reaches no door above, yet should still be named in logs
// and in the rate-limit key.
//
// A THIRD writer is what this refuses. The keys are exported, so anything
// may set them, and a writer fed by caller-controlled input would make the
// dimension forgeable.
writers := sourceSitesSettingWoolIdentity(t)
require.Equal(t, []string{
"pkg/adapters/grpc_auth_interceptor.go",
"pkg/auth/database_identity.go",
}, writers,
"wool's identity keys have exactly two writers, both named above; a third makes the dimension "+
"forgeable, and the rate limiter and the organization-defaulted audit read both consult it")
}
41 changes: 41 additions & 0 deletions module/services/accounts/code/pkg/keyservice/keyservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,44 @@ func (e *statusError) Error() string {
return fmt.Sprintf("%s request returned %d", e.service, e.status)
}
func (e *statusError) HTTPStatusCode() int { return e.status }

// KeyFactory binds another key of the same key service, named by a reference the
// platform recorded for an organization.
//
// The reference is OPAQUE above this interface: only the backend knows whether
// it is a Vault transit key name or a cloud resource name, which is what keeps
// the vendor out of the caller and out of the database.
type KeyFactory interface {
SealerFor(ctx context.Context, keyRef string) (Sealer, error)
}

// OrgKeyBinding is what the platform recorded about an organization's key.
type OrgKeyBinding struct {
// KeyRef is the key, as the bound backend names one.
KeyRef string
// CustomerHeld marks a key this deployment does not control.
CustomerHeld bool
// RevokedReason is set once the key is gone — destroyed on request, or
// revoked by the customer. Every credential sealed under it is unreadable
// from that moment, which is the point.
RevokedReason string
}

// Revoked reports whether the key is gone.
func (b OrgKeyBinding) Revoked() bool { return b.RevokedReason != "" }

// OrgKeyBindings answers which key seals an organization's credentials. A nil
// binding means the organization has none of its own and is sealed under the
// deployment's key, which is the default and stays the default.
type OrgKeyBindings interface {
OrgKeyBinding(ctx context.Context, orgID string) (*OrgKeyBinding, error)
}

// ErrKeyRevoked reports that an organization's key is gone, so its credentials
// are unreadable by design.
//
// It is deliberately NOT business.ErrInvalidSecretEnvelope: callers treat that
// sentinel as a credential the user should re-enter, and telling a customer to
// reconnect a source whose data they instructed us to destroy is the opposite of
// what crypto-shredding is for.
var ErrKeyRevoked = errors.New("key service: the organization's key has been revoked")
35 changes: 35 additions & 0 deletions module/services/accounts/code/pkg/keyservice/kms.go
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,9 @@ func (k *KMSSealer) Open(ctx context.Context, purpose, payload string) (string,
}

func (k *KMSSealer) MAC(ctx context.Context, plaintext string) (string, error) {
if !k.macKeyBound() {
return "", errors.New("key service: this sealer is bound to an organization's key and may not compute the keyed hash; API-key lookup is by that hash, so it is always the deployment's own")
}
var result struct {
MAC string `json:"mac"`
}
Expand Down Expand Up @@ -579,3 +582,35 @@ func (w *KMSKeyWrap) UnwrapSigningKey(ctx context.Context, wrapped string) (ed25
}
return ed25519.NewKeyFromSeed(seed), nil
}

// SealerFor binds another Cloud KMS key, for an organization whose credentials
// are sealed under a key of its own.
//
// The key is validated and round-tripped exactly as the deployment's own is:
// purpose, algorithm, and a seal/open probe. An organization pointed at a key
// that is well-formed and simply not usable must fail when the binding is read,
// not at the first credential that needs it.
func (k *KMSSealer) SealerFor(ctx context.Context, keyRef string) (Sealer, error) {
if !cryptoKeyName.MatchString(keyRef) {
return nil, fmt.Errorf("key service: an organization's Cloud KMS key must be a cryptoKey resource name (projects/<project>/locations/<location>/keyRings/<ring>/cryptoKeys/<key>), got %q", keyRef)
}
if keyRef == k.envelopeKey {
return nil, fmt.Errorf("key service: %s is this deployment's own envelope key and cannot be an organization's", keyRef)
}
sealer, err := newKMSEnvelope(ctx, k.client, keyRef)
if err != nil {
return nil, err
}
// The keyed hash never moves off the deployment's key, so a per-organization
// sealer carries no MAC key and MAC on it is a programming error rather than
// a silent hash under the wrong key.
return sealer, nil
}

// MAC refuses on a sealer bound to an organization's key.
//
// API-key lookup is BY the hash, so a hash computed under an organization's key
// could never be found: the lookup happens before the organization is known. The
// deployment's own sealer is the only one that may compute it, and this refuses
// rather than quietly producing a value nothing will ever match.
func (k *KMSSealer) macKeyBound() bool { return k.macVersion != "" }
Loading
Loading