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
22 changes: 21 additions & 1 deletion cmd/identity-service/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/agynio/identity/internal/db"
"github.com/agynio/identity/internal/server"
"github.com/agynio/identity/internal/store"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
Expand Down Expand Up @@ -56,8 +57,27 @@ func run() error {
}
defer authConn.Close()

identityStore := store.New(pool)
authorizationClient := authorizationv1.NewAuthorizationServiceClient(authConn)

grpcServer := grpc.NewServer()
identityv1.RegisterIdentityServiceServer(grpcServer, server.New(store.New(pool), authorizationv1.NewAuthorizationServiceClient(authConn)))
identityv1.RegisterIdentityServiceServer(grpcServer, server.New(identityStore, authorizationClient))

// The platform admin identity comes from configuration because the
// controller that provisions everything else authenticates as it, so
// nothing can create it through an API. This service owns the record and
// the cluster admin relation, and converges both in the background --
// nothing here waits on it, and every request is served meanwhile.
if cfg.PlatformIdentityID != "" {
platformIdentityID, err := uuid.Parse(cfg.PlatformIdentityID)
if err != nil {
return fmt.Errorf("PLATFORM_IDENTITY_ID: %w", err)
}
go server.NewPlatformIdentity(identityStore, authorizationClient).
EnsureWithRetry(ctx, platformIdentityID, func(format string, args ...any) {
log.Printf(format, args...)
})
}

lis, err := net.Listen("tcp", cfg.GRPCAddress)
if err != nil {
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ package config
import (
"fmt"
"os"
"strings"
)

type Config struct {
GRPCAddress string
DatabaseURL string
AuthorizationAddress string
// PlatformIdentityID is the platform admin identity this service registers
// and grants cluster admin at startup. Empty leaves both alone, which is
// what an install with no provisioning wants.
PlatformIdentityID string
}

func FromEnv() (Config, error) {
Expand All @@ -17,6 +22,7 @@ func FromEnv() (Config, error) {
if cfg.GRPCAddress == "" {
cfg.GRPCAddress = ":50051"
}
cfg.PlatformIdentityID = strings.TrimSpace(os.Getenv("PLATFORM_IDENTITY_ID"))
cfg.DatabaseURL = os.Getenv("DATABASE_URL")
if cfg.DatabaseURL == "" {
return Config{}, fmt.Errorf("DATABASE_URL must be set")
Expand Down
125 changes: 125 additions & 0 deletions internal/server/platform_identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package server

import (
"context"
"errors"
"fmt"
"time"

authorizationv1 "github.com/agynio/identity/.gen/go/agynio/api/authorization/v1"
"github.com/agynio/identity/internal/store"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// The platform admin identity is the one identity nothing mints: it comes from
// configuration, because the controller that provisions everything else
// authenticates as it and so cannot create it. This service owns both halves of
// it -- the record and the cluster admin relation that makes it an admin -- and
// converges them at its own startup.
//
// Deliberately not a request. Granting on RegisterIdentity would let any caller
// that can reach this service hand itself cluster admin by naming the platform
// type, and every service calls RegisterIdentity. Reading the id from
// configuration means the grant names exactly one identity and nothing can ask
// for it.
type PlatformIdentity struct {
store identityStore
authorizationClient authorizationChecker
}

func NewPlatformIdentity(store identityStore, authorizationClient authorizationChecker) *PlatformIdentity {
return &PlatformIdentity{store: store, authorizationClient: authorizationClient}
}

// Ensure registers the identity and grants it admin on cluster:global. Safe to
// repeat: a record that already has the platform type is left alone, and
// writing a tuple that exists is not an error. Repeating is the point -- a
// tuple lost out from under the platform is repaired on the next start rather
// than by an operator.
func (p *PlatformIdentity) Ensure(ctx context.Context, identityID uuid.UUID) error {
if err := p.ensureRecord(ctx, identityID); err != nil {
return err
}
return p.grantClusterAdmin(ctx, identityID)
}

func (p *PlatformIdentity) ensureRecord(ctx context.Context, identityID uuid.UUID) error {
err := p.store.RegisterIdentity(ctx, identityID, dbIdentityTypePlatform)
if err == nil {
return nil
}
var exists *store.AlreadyExistsError
if !errors.As(err, &exists) {
return fmt.Errorf("register: %w", err)
}

// Installs that predate the platform type carry this identity as a user,
// and nothing else would ever correct the row. Only widen from that type:
// anything else under this id belongs to someone, and adopting a runner
// would hand it cluster admin.
current, err := p.store.GetIdentityType(ctx, identityID)
if err != nil {
return fmt.Errorf("read the existing type: %w", err)
}
switch current {
case dbIdentityTypePlatform:
return nil
case dbIdentityTypeUser:
return p.store.SetIdentityType(ctx, identityID, dbIdentityTypePlatform)
default:
return fmt.Errorf("identity %s is registered as type %d, so it is not this platform's admin identity", identityID, current)
}
}

func (p *PlatformIdentity) grantClusterAdmin(ctx context.Context, identityID uuid.UUID) error {
_, err := p.authorizationClient.Write(ctx, &authorizationv1.WriteRequest{
Writes: []*authorizationv1.TupleKey{{
User: identityObjectPrefix + identityID.String(),
Relation: adminRelation,
Object: clusterObject,
}},
})
if err != nil && status.Code(err) == codes.AlreadyExists {
return nil
}
if err != nil {
return fmt.Errorf("grant cluster admin: %w", err)
}
return nil
}

// EnsureWithRetry converges the platform identity in the background.
//
// Retried rather than attempted once because the authorization service and this
// service start together, and a platform with no administrator of its own is
// not a state anything else recovers from. This is convergence of a fact this
// service owns, not a provisioning step: nothing waits on it, and every other
// request is served meanwhile.
func (p *PlatformIdentity) EnsureWithRetry(ctx context.Context, identityID uuid.UUID, log func(string, ...any)) {
const (
initialBackoff = 2 * time.Second
maxBackoff = 30 * time.Second
callTimeout = 10 * time.Second
)

for backoff := initialBackoff; ; {
callCtx, cancel := context.WithTimeout(ctx, callTimeout)
err := p.Ensure(callCtx, identityID)
cancel()
if err == nil {
log("platform identity %s registered and granted cluster admin", identityID)
return
}
log("platform identity %s not ready (%v); retrying in %s", identityID, err, backoff)
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
if backoff *= 2; backoff > maxBackoff {
backoff = maxBackoff
}
}
}
106 changes: 106 additions & 0 deletions internal/server/platform_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package server

import (
"context"
"testing"

identityv1 "github.com/agynio/identity/.gen/go/agynio/api/identity/v1"
"github.com/agynio/identity/internal/store"
"github.com/google/uuid"
)

func TestEnsureRegistersAndGrants(t *testing.T) {
auth := &fakeAuthClient{}
identityID := uuid.New()

if err := NewPlatformIdentity(&fakeStore{}, auth).Ensure(context.Background(), identityID); err != nil {
t.Fatalf("unexpected error: %v", err)
}

if len(auth.writes) != 1 {
t.Fatalf("expected one tuple write, got %d", len(auth.writes))
}
tuple := auth.writes[0]
if tuple.GetUser() != identityObjectPrefix+identityID.String() ||
tuple.GetRelation() != adminRelation || tuple.GetObject() != clusterObject {
t.Fatalf("unexpected tuple: %s %s %s", tuple.GetUser(), tuple.GetRelation(), tuple.GetObject())
}
}

// Converging is the point: the grant is rewritten on every start, so a tuple
// lost out from under the platform is repaired without an operator.
func TestEnsureIsRepeatable(t *testing.T) {
auth := &fakeAuthClient{}
identityID := uuid.New()
fake := &fakeStore{
registerErr: store.AlreadyExists("identity"),
identityTypes: map[uuid.UUID]int16{identityID: dbIdentityTypePlatform},
}

if err := NewPlatformIdentity(fake, auth).Ensure(context.Background(), identityID); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(auth.writes) != 1 {
t.Fatalf("expected the grant to be written anyway, got %d", len(auth.writes))
}
}

// An install that predates the platform type carries this identity as a user.
func TestEnsureAdoptsAPreviouslyUserTypedRecord(t *testing.T) {
identityID := uuid.New()
fake := &fakeStore{
registerErr: store.AlreadyExists("identity"),
identityTypes: map[uuid.UUID]int16{identityID: dbIdentityTypeUser},
}

if err := NewPlatformIdentity(fake, &fakeAuthClient{}).Ensure(context.Background(), identityID); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := fake.identityTypes[identityID]; got != dbIdentityTypePlatform {
t.Fatalf("expected the record adopted as platform, got type %d", got)
}
}

// Anything else under that id belongs to someone: adopting a runner would hand
// it cluster admin.
func TestEnsureRefusesToAdoptANonUserRecord(t *testing.T) {
identityID := uuid.New()
auth := &fakeAuthClient{}
fake := &fakeStore{
registerErr: store.AlreadyExists("identity"),
identityTypes: map[uuid.UUID]int16{identityID: dbIdentityTypeRunner},
}

if err := NewPlatformIdentity(fake, auth).Ensure(context.Background(), identityID); err == nil {
t.Fatal("expected an error")
}
if fake.identityTypes[identityID] != dbIdentityTypeRunner {
t.Fatal("expected the record left alone")
}
if len(auth.writes) != 0 {
t.Fatalf("expected no grant, got %d writes", len(auth.writes))
}
}

// The grant is not reachable over the wire. Every service calls
// RegisterIdentity, so granting there would let any of them name the platform
// type and hand itself cluster admin.
func TestRegisterIdentityGrantsNothing(t *testing.T) {
for _, identityType := range []identityv1.IdentityType{
identityv1.IdentityType_IDENTITY_TYPE_PLATFORM,
identityv1.IdentityType_IDENTITY_TYPE_USER,
identityv1.IdentityType_IDENTITY_TYPE_RUNNER,
} {
auth := &fakeAuthClient{}
server := New(&fakeStore{}, auth)
if _, err := server.RegisterIdentity(context.Background(), &identityv1.RegisterIdentityRequest{
IdentityId: uuid.New().String(),
IdentityType: identityType,
}); err != nil {
t.Fatalf("%s: unexpected error: %v", identityType, err)
}
if len(auth.writes) != 0 {
t.Fatalf("%s: expected no tuple write, got %d", identityType, len(auth.writes))
}
}
}
Loading
Loading