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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
version: 1.66.0

- name: Generate protobuf bindings
run: buf generate https://github.com/agynio/api.git#branch=noa/issue-161-identity-foundation --path proto/agynio/api/identity/v1 --path proto/agynio/api/authorization/v1
run: buf generate buf.build/agynio/api --path agynio/api/identity/v1 --path agynio/api/authorization/v1

- name: Run tests
run: go test ./...
Expand Down
2 changes: 2 additions & 0 deletions internal/server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const (
identityHeaderKey = "x-identity-id"
identityObjectPrefix = "identity:"
organizationObjectPrefix = "organization:"
clusterObject = "cluster:global"
adminRelation = "admin"
)

func identityIDFromContext(ctx context.Context) (uuid.UUID, error) {
Expand Down
2 changes: 2 additions & 0 deletions internal/server/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
dbIdentityTypeRunner int16 = 4
dbIdentityTypeApp int16 = 5
dbIdentityTypeAgentInstance int16 = 6
dbIdentityTypePlatform int16 = 8
)

var identityTypeMappings = []struct {
Expand All @@ -23,6 +24,7 @@
{proto: identityv1.IdentityType_IDENTITY_TYPE_RUNNER, db: dbIdentityTypeRunner},
{proto: identityv1.IdentityType_IDENTITY_TYPE_APP, db: dbIdentityTypeApp},
{proto: identityv1.IdentityType_IDENTITY_TYPE_AGENT_INSTANCE, db: dbIdentityTypeAgentInstance},
{proto: identityv1.IdentityType_IDENTITY_TYPE_PLATFORM, db: dbIdentityTypePlatform},

Check failure on line 27 in internal/server/converter.go

View workflow job for this annotation

GitHub Actions / e2e

undefined: identityv1.IdentityType_IDENTITY_TYPE_PLATFORM
}

func identityTypeFromProto(value identityv1.IdentityType) (int16, error) {
Expand Down
19 changes: 19 additions & 0 deletions internal/server/nicknames_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type fakeAuthClient struct {
allowed bool
lastRequest *authorizationv1.CheckRequest
err error
writes []*authorizationv1.TupleKey
writeErr error
}

func (f *fakeAuthClient) Check(_ context.Context, req *authorizationv1.CheckRequest, _ ...grpc.CallOption) (*authorizationv1.CheckResponse, error) {
Expand All @@ -30,6 +32,14 @@ func (f *fakeAuthClient) Check(_ context.Context, req *authorizationv1.CheckRequ
return &authorizationv1.CheckResponse{Allowed: f.allowed}, nil
}

func (f *fakeAuthClient) Write(_ context.Context, req *authorizationv1.WriteRequest, _ ...grpc.CallOption) (*authorizationv1.WriteResponse, error) {
if f.writeErr != nil {
return nil, f.writeErr
}
f.writes = append(f.writes, req.GetWrites()...)
return &authorizationv1.WriteResponse{}, nil
}

type fakeStore struct {
batchNicknames map[uuid.UUID]store.NicknameEntry
batchErr error
Expand All @@ -39,9 +49,18 @@ type fakeStore struct {
lastNickname string
lastInstanceSuffix *string
resolution store.NicknameResolution
registerErr error
}

func (f *fakeStore) RegisterIdentity(context.Context, uuid.UUID, int16) error {
return f.registerErr
}

func (f *fakeStore) SetIdentityType(_ context.Context, identityID uuid.UUID, identityType int16) error {
if f.identityTypes == nil {
f.identityTypes = map[uuid.UUID]int16{}
}
f.identityTypes[identityID] = identityType
return nil
}

Expand Down
127 changes: 127 additions & 0 deletions internal/server/register_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

func TestRegisterIdentityGrantsClusterAdminToPlatformIdentity(t *testing.T) {
auth := &fakeAuthClient{}
server := New(&fakeStore{}, auth)
identityID := uuid.New()

if _, err := server.RegisterIdentity(context.Background(), &identityv1.RegisterIdentityRequest{
IdentityId: identityID.String(),
IdentityType: identityv1.IdentityType_IDENTITY_TYPE_PLATFORM,
}); 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() {
t.Fatalf("unexpected user: %s", tuple.GetUser())
}
if tuple.GetRelation() != adminRelation || tuple.GetObject() != clusterObject {
t.Fatalf("unexpected tuple: %s on %s", tuple.GetRelation(), tuple.GetObject())
}
}

// The Gateway re-registers the platform identity on every start, and the record
// already exists from the first one. The grant still has to converge, so a lost
// tuple is repaired without an operator.
func TestRegisterIdentityGrantsClusterAdminWhenAlreadyRegistered(t *testing.T) {
auth := &fakeAuthClient{}
identityID := uuid.New()
fake := &fakeStore{
registerErr: store.AlreadyExists("identity"),
identityTypes: map[uuid.UUID]int16{identityID: dbIdentityTypePlatform},
}
server := New(fake, auth)

_, err := server.RegisterIdentity(context.Background(), &identityv1.RegisterIdentityRequest{
IdentityId: identityID.String(),
IdentityType: identityv1.IdentityType_IDENTITY_TYPE_PLATFORM,
})
if status.Code(err) != codes.AlreadyExists {
t.Fatalf("expected AlreadyExists, got %v", err)
}
if len(auth.writes) != 1 {
t.Fatalf("expected the grant to be written anyway, got %d writes", len(auth.writes))
}
}

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

_, err := New(fake, &fakeAuthClient{}).RegisterIdentity(context.Background(), &identityv1.RegisterIdentityRequest{
IdentityId: identityID.String(),
IdentityType: identityv1.IdentityType_IDENTITY_TYPE_PLATFORM,
})
if status.Code(err) != codes.AlreadyExists {
t.Fatalf("expected AlreadyExists, got %v", err)
}
if got := fake.identityTypes[identityID]; got != dbIdentityTypePlatform {
t.Fatalf("expected the record to be adopted as platform, got type %d", got)
}
}

// Anything else under that ID belongs to someone: a runner adopted as the
// platform admin would be handed cluster admin.
func TestRegisterIdentityRefusesToAdoptANonUserIdentity(t *testing.T) {
identityID := uuid.New()
auth := &fakeAuthClient{}
fake := &fakeStore{
registerErr: store.AlreadyExists("identity"),
identityTypes: map[uuid.UUID]int16{identityID: dbIdentityTypeRunner},
}

_, err := New(fake, auth).RegisterIdentity(context.Background(), &identityv1.RegisterIdentityRequest{
IdentityId: identityID.String(),
IdentityType: identityv1.IdentityType_IDENTITY_TYPE_PLATFORM,
})
if status.Code(err) != codes.Internal {
t.Fatalf("expected Internal, got %v", err)
}
if fake.identityTypes[identityID] != dbIdentityTypeRunner {
t.Fatalf("expected the record to be left alone")
}
if len(auth.writes) != 0 {
t.Fatalf("expected no grant, got %d writes", len(auth.writes))
}
}

func TestRegisterIdentityGrantsNothingToOtherTypes(t *testing.T) {
for _, identityType := range []identityv1.IdentityType{
identityv1.IdentityType_IDENTITY_TYPE_USER,
identityv1.IdentityType_IDENTITY_TYPE_RUNNER,
identityv1.IdentityType_IDENTITY_TYPE_APP,
identityv1.IdentityType_IDENTITY_TYPE_AGENT,
} {
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))
}
}
}
66 changes: 64 additions & 2 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package server
import (
"context"
"errors"
"fmt"

authorizationv1 "github.com/agynio/identity/.gen/go/agynio/api/authorization/v1"
identityv1 "github.com/agynio/identity/.gen/go/agynio/api/identity/v1"
Expand All @@ -15,6 +16,7 @@ import (

type identityStore interface {
RegisterIdentity(context.Context, uuid.UUID, int16) error
SetIdentityType(context.Context, uuid.UUID, int16) error
GetIdentityType(context.Context, uuid.UUID) (int16, error)
BatchGetIdentityTypes(context.Context, []uuid.UUID) (map[uuid.UUID]int16, error)
SetNickname(context.Context, uuid.UUID, uuid.UUID, *uuid.UUID, string, *string) error
Expand All @@ -25,6 +27,7 @@ type identityStore interface {

type authorizationChecker interface {
Check(context.Context, *authorizationv1.CheckRequest, ...grpc.CallOption) (*authorizationv1.CheckResponse, error)
Write(context.Context, *authorizationv1.WriteRequest, ...grpc.CallOption) (*authorizationv1.WriteResponse, error)
}

type Server struct {
Expand All @@ -46,12 +49,71 @@ func (s *Server) RegisterIdentity(ctx context.Context, req *identityv1.RegisterI
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "identity_type: %v", err)
}
if err := s.store.RegisterIdentity(ctx, identityID, identityType); err != nil {
return nil, toStatusError(err)
registerErr := s.store.RegisterIdentity(ctx, identityID, identityType)
// A platform identity is a cluster admin by definition, so the grant is
// ensured here rather than by whoever configured the identity. It is
// re-registered on every Gateway start, and the grant converges on each
// one, so a tuple lost out from under it is repaired without an operator.
if identityType == dbIdentityTypePlatform && (registerErr == nil || isAlreadyExists(registerErr)) {
// Installs that predate the platform type carry this identity as a
// user, and nothing else would ever correct the row.
if isAlreadyExists(registerErr) {
if err := s.adoptPlatformIdentity(ctx, identityID); err != nil {
return nil, status.Errorf(codes.Internal, "adopt platform identity: %v", err)
}
}
if err := s.grantClusterAdmin(ctx, identityID); err != nil {
return nil, status.Errorf(codes.Internal, "grant cluster admin: %v", err)
}
}
if registerErr != nil {
return nil, toStatusError(registerErr)
}
return &identityv1.RegisterIdentityResponse{}, nil
}

// adoptPlatformIdentity rewrites an existing record to the platform type. Only
// widens from the user type the Gateway used to register it with: anything else
// under this ID is someone else's identity and is left alone.
func (s *Server) adoptPlatformIdentity(ctx context.Context, identityID uuid.UUID) error {
current, err := s.store.GetIdentityType(ctx, identityID)
if err != nil {
return err
}
if current == dbIdentityTypePlatform {
return nil
}
if current != dbIdentityTypeUser {
return fmt.Errorf("identity %s is registered as type %d, not a platform identity", identityID, current)
}
return s.store.SetIdentityType(ctx, identityID, dbIdentityTypePlatform)
}

// grantClusterAdmin writes the platform admin identity's admin relation on
// cluster:global through the service that owns it. Writing an existing tuple is
// not an error, so this is safe to repeat.
func (s *Server) grantClusterAdmin(ctx context.Context, identityID uuid.UUID) error {
if s.authorizationClient == nil {
return errors.New("authorization client not configured")
}
_, err := s.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
}
return err
}

func isAlreadyExists(err error) bool {
var exists *store.AlreadyExistsError
return errors.As(err, &exists)
}

func (s *Server) GetIdentityType(ctx context.Context, req *identityv1.GetIdentityTypeRequest) (*identityv1.GetIdentityTypeResponse, error) {
identityID, err := parseUUID(req.GetIdentityId())
if err != nil {
Expand Down
14 changes: 14 additions & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ func (s *Store) RegisterIdentity(ctx context.Context, identityID uuid.UUID, iden
return nil
}

// SetIdentityType rewrites the type of an already-registered identity. Only the
// platform admin identity uses it: installs that predate its own type carry it
// as a user, and nothing else would ever correct the row.
func (s *Store) SetIdentityType(ctx context.Context, identityID uuid.UUID, identityType int16) error {
tag, err := s.pool.Exec(ctx, `UPDATE identities SET identity_type = $2 WHERE identity_id = $1`, identityID, identityType)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return NotFound("identity")
}
return nil
}

func (s *Store) GetIdentityType(ctx context.Context, identityID uuid.UUID) (int16, error) {
var identityType int16
if err := s.pool.QueryRow(ctx, `SELECT identity_type FROM identities WHERE identity_id = $1`, identityID).Scan(&identityType); err != nil {
Expand Down
Loading