diff --git a/internal/server/server.go b/internal/server/server.go index ecc2868..1636fe2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "regexp" identityv1 "github.com/agynio/identity/.gen/go/agynio/api/identity/v1" "github.com/agynio/identity/internal/store" @@ -88,6 +89,90 @@ func (s *Server) BatchGetIdentityTypes(ctx context.Context, req *identityv1.Batc return &identityv1.BatchGetIdentityTypesResponse{Entries: entries}, nil } +func (s *Server) SetNickname(ctx context.Context, req *identityv1.SetNicknameRequest) (*identityv1.SetNicknameResponse, error) { + orgID, err := parseUUID(req.GetOrganizationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "organization_id: %v", err) + } + identityID, err := parseUUID(req.GetIdentityId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "identity_id: %v", err) + } + nickname, err := parseNickname(req.GetNickname()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "nickname: %v", err) + } + + var installationID *uuid.UUID + if req.InstallationId != nil { + parsed, err := parseUUID(req.GetInstallationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "installation_id: %v", err) + } + installationID = &parsed + } + + if err := s.store.SetNickname(ctx, orgID, identityID, nickname, installationID); err != nil { + return nil, toStatusError(err) + } + return &identityv1.SetNicknameResponse{}, nil +} + +func (s *Server) RemoveNickname(ctx context.Context, req *identityv1.RemoveNicknameRequest) (*identityv1.RemoveNicknameResponse, error) { + orgID, err := parseUUID(req.GetOrganizationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "organization_id: %v", err) + } + identityID, err := parseUUID(req.GetIdentityId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "identity_id: %v", err) + } + + var installationID *uuid.UUID + if req.InstallationId != nil { + parsed, err := parseUUID(req.GetInstallationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "installation_id: %v", err) + } + installationID = &parsed + } + + if err := s.store.RemoveNickname(ctx, orgID, identityID, installationID); err != nil { + return nil, toStatusError(err) + } + return &identityv1.RemoveNicknameResponse{}, nil +} + +func (s *Server) ResolveNickname(ctx context.Context, req *identityv1.ResolveNicknameRequest) (*identityv1.ResolveNicknameResponse, error) { + orgID, err := parseUUID(req.GetOrganizationId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "organization_id: %v", err) + } + nickname, err := parseNickname(req.GetNickname()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "nickname: %v", err) + } + + resolved, err := s.store.ResolveNickname(ctx, orgID, nickname) + if err != nil { + return nil, toStatusError(err) + } + identityType, err := identityTypeToProto(resolved.IdentityType) + if err != nil { + return nil, status.Errorf(codes.Internal, "internal error: %v", err) + } + + resp := &identityv1.ResolveNicknameResponse{ + IdentityId: resolved.IdentityID.String(), + IdentityType: identityType, + } + if resolved.InstallationID != nil { + installationID := resolved.InstallationID.String() + resp.InstallationId = &installationID + } + return resp, nil +} + func parseUUID(value string) (uuid.UUID, error) { if value == "" { return uuid.UUID{}, fmt.Errorf("value is empty") @@ -99,6 +184,21 @@ func parseUUID(value string) (uuid.UUID, error) { return id, nil } +var nicknamePattern = regexp.MustCompile("^[a-z0-9_-]+$") + +func parseNickname(value string) (string, error) { + if value == "" { + return "", fmt.Errorf("value is empty") + } + if len(value) > 32 { + return "", fmt.Errorf("value exceeds 32 characters") + } + if !nicknamePattern.MatchString(value) { + return "", fmt.Errorf("value must match ^[a-z0-9_-]+$") + } + return value, nil +} + func toStatusError(err error) error { var notFound *store.NotFoundError if errors.As(err, ¬Found) { diff --git a/internal/store/store.go b/internal/store/store.go index f9d2f4b..721a000 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -15,6 +15,12 @@ type Store struct { pool *pgxpool.Pool } +type ResolvedNickname struct { + IdentityID uuid.UUID + IdentityType int16 + InstallationID *uuid.UUID +} + func New(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } @@ -72,3 +78,70 @@ func (s *Store) BatchGetIdentityTypes(ctx context.Context, identityIDs []uuid.UU } return identityTypes, nil } + +func (s *Store) SetNickname(ctx context.Context, orgID uuid.UUID, identityID uuid.UUID, nickname string, installationID *uuid.UUID) error { + var err error + if installationID == nil { + _, err = s.pool.Exec(ctx, `INSERT INTO org_nicknames (org_id, identity_id, nickname) + VALUES ($1, $2, $3) + ON CONFLICT (org_id, identity_id) WHERE installation_id IS NULL + DO UPDATE SET nickname = EXCLUDED.nickname`, orgID, identityID, nickname) + } else { + _, err = s.pool.Exec(ctx, `INSERT INTO org_nicknames (org_id, identity_id, installation_id, nickname) + VALUES ($1, $2, $3, $4) + ON CONFLICT (org_id, installation_id) WHERE installation_id IS NOT NULL + DO UPDATE SET nickname = EXCLUDED.nickname, identity_id = EXCLUDED.identity_id`, orgID, identityID, *installationID, nickname) + } + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case "23505": + return AlreadyExists("nickname") + case "23503": + return NotFound("identity") + } + } + return err + } + return nil +} + +func (s *Store) RemoveNickname(ctx context.Context, orgID uuid.UUID, identityID uuid.UUID, installationID *uuid.UUID) error { + var ( + commandTag pgconn.CommandTag + err error + ) + if installationID == nil { + commandTag, err = s.pool.Exec(ctx, `DELETE FROM org_nicknames WHERE org_id = $1 AND identity_id = $2 AND installation_id IS NULL`, orgID, identityID) + } else { + commandTag, err = s.pool.Exec(ctx, `DELETE FROM org_nicknames WHERE org_id = $1 AND identity_id = $2 AND installation_id = $3`, orgID, identityID, *installationID) + } + if err != nil { + return err + } + if commandTag.RowsAffected() == 0 { + return NotFound("nickname") + } + return nil +} + +func (s *Store) ResolveNickname(ctx context.Context, orgID uuid.UUID, nickname string) (ResolvedNickname, error) { + var resolved ResolvedNickname + var installationID pgtype.UUID + if err := s.pool.QueryRow(ctx, `SELECT org_nicknames.identity_id, identities.identity_type, org_nicknames.installation_id + FROM org_nicknames + JOIN identities ON identities.identity_id = org_nicknames.identity_id + WHERE org_nicknames.org_id = $1 AND org_nicknames.nickname = $2`, orgID, nickname). + Scan(&resolved.IdentityID, &resolved.IdentityType, &installationID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ResolvedNickname{}, NotFound("nickname") + } + return ResolvedNickname{}, err + } + if installationID.Valid { + id := uuid.UUID(installationID.Bytes) + resolved.InstallationID = &id + } + return resolved, nil +} diff --git a/migrations/0002_org_nicknames.sql b/migrations/0002_org_nicknames.sql new file mode 100644 index 0000000..e3a4d67 --- /dev/null +++ b/migrations/0002_org_nicknames.sql @@ -0,0 +1,15 @@ +CREATE TABLE org_nicknames ( + org_id UUID NOT NULL, + identity_id UUID NOT NULL REFERENCES identities (identity_id), + installation_id UUID NULL, + nickname TEXT NOT NULL, + PRIMARY KEY (org_id, nickname) +); + +CREATE UNIQUE INDEX org_nicknames_org_identity_idx + ON org_nicknames (org_id, identity_id) + WHERE installation_id IS NULL; + +CREATE UNIQUE INDEX org_nicknames_org_installation_idx + ON org_nicknames (org_id, installation_id) + WHERE installation_id IS NOT NULL; diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 5f94c5b..b0a4782 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -5,6 +5,7 @@ package e2e import ( "context" + "strings" "testing" "time" @@ -71,6 +72,114 @@ func TestIdentityServiceE2E(t *testing.T) { require.True(t, hasIdentityType(batchResp.Entries, secondID, identityv1.IdentityType_IDENTITY_TYPE_AGENT)) }) + t.Run("Nicknames", func(t *testing.T) { + orgID := uuid.NewString() + userID := uuid.NewString() + agentID := uuid.NewString() + appID := uuid.NewString() + + _, err := client.RegisterIdentity(ctx, &identityv1.RegisterIdentityRequest{ + IdentityId: userID, + IdentityType: identityv1.IdentityType_IDENTITY_TYPE_USER, + }) + require.NoError(t, err) + _, err = client.RegisterIdentity(ctx, &identityv1.RegisterIdentityRequest{ + IdentityId: agentID, + IdentityType: identityv1.IdentityType_IDENTITY_TYPE_AGENT, + }) + require.NoError(t, err) + _, err = client.RegisterIdentity(ctx, &identityv1.RegisterIdentityRequest{ + IdentityId: appID, + IdentityType: identityv1.IdentityType_IDENTITY_TYPE_APP, + }) + require.NoError(t, err) + + _, err = client.SetNickname(ctx, &identityv1.SetNicknameRequest{ + OrganizationId: orgID, + IdentityId: userID, + Nickname: "alice", + }) + require.NoError(t, err) + _, err = client.SetNickname(ctx, &identityv1.SetNicknameRequest{ + OrganizationId: orgID, + IdentityId: userID, + Nickname: "alice-updated", + }) + require.NoError(t, err) + + resolveResp, err := client.ResolveNickname(ctx, &identityv1.ResolveNicknameRequest{ + OrganizationId: orgID, + Nickname: "alice-updated", + }) + require.NoError(t, err) + require.Equal(t, userID, resolveResp.IdentityId) + require.Equal(t, identityv1.IdentityType_IDENTITY_TYPE_USER, resolveResp.IdentityType) + require.Nil(t, resolveResp.InstallationId) + + _, err = client.ResolveNickname(ctx, &identityv1.ResolveNicknameRequest{ + OrganizationId: orgID, + Nickname: "alice", + }) + requireStatusCode(t, err, codes.NotFound) + + _, err = client.SetNickname(ctx, &identityv1.SetNicknameRequest{ + OrganizationId: orgID, + IdentityId: agentID, + Nickname: "alice-updated", + }) + requireStatusCode(t, err, codes.AlreadyExists) + + installationOne := uuid.NewString() + _, err = client.SetNickname(ctx, &identityv1.SetNicknameRequest{ + OrganizationId: orgID, + IdentityId: appID, + Nickname: "app-main", + InstallationId: &installationOne, + }) + require.NoError(t, err) + installationTwo := uuid.NewString() + _, err = client.SetNickname(ctx, &identityv1.SetNicknameRequest{ + OrganizationId: orgID, + IdentityId: appID, + Nickname: "app-secondary", + InstallationId: &installationTwo, + }) + require.NoError(t, err) + + appResolve, err := client.ResolveNickname(ctx, &identityv1.ResolveNicknameRequest{ + OrganizationId: orgID, + Nickname: "app-main", + }) + require.NoError(t, err) + require.Equal(t, appID, appResolve.IdentityId) + require.Equal(t, identityv1.IdentityType_IDENTITY_TYPE_APP, appResolve.IdentityType) + require.NotNil(t, appResolve.InstallationId) + require.Equal(t, installationOne, appResolve.GetInstallationId()) + + _, err = client.RemoveNickname(ctx, &identityv1.RemoveNicknameRequest{ + OrganizationId: orgID, + IdentityId: appID, + InstallationId: &installationOne, + }) + require.NoError(t, err) + _, err = client.ResolveNickname(ctx, &identityv1.ResolveNicknameRequest{ + OrganizationId: orgID, + Nickname: "app-main", + }) + requireStatusCode(t, err, codes.NotFound) + + _, err = client.RemoveNickname(ctx, &identityv1.RemoveNicknameRequest{ + OrganizationId: orgID, + IdentityId: userID, + }) + require.NoError(t, err) + _, err = client.ResolveNickname(ctx, &identityv1.ResolveNicknameRequest{ + OrganizationId: orgID, + Nickname: "alice-updated", + }) + requireStatusCode(t, err, codes.NotFound) + }) + t.Run("NegativePaths", func(t *testing.T) { _, err := client.GetIdentityType(ctx, &identityv1.GetIdentityTypeRequest{IdentityId: uuid.NewString()}) requireStatusCode(t, err, codes.NotFound) @@ -86,6 +195,48 @@ func TestIdentityServiceE2E(t *testing.T) { _, err = client.BatchGetIdentityTypes(ctx, &identityv1.BatchGetIdentityTypesRequest{IdentityIds: []string{"bad"}}) requireStatusCode(t, err, codes.InvalidArgument) + + orgID := uuid.NewString() + identityID := uuid.NewString() + _, err = client.RegisterIdentity(ctx, &identityv1.RegisterIdentityRequest{ + IdentityId: identityID, + IdentityType: identityv1.IdentityType_IDENTITY_TYPE_USER, + }) + require.NoError(t, err) + + _, err = client.SetNickname(ctx, &identityv1.SetNicknameRequest{ + OrganizationId: orgID, + IdentityId: identityID, + Nickname: "", + }) + requireStatusCode(t, err, codes.InvalidArgument) + + _, err = client.SetNickname(ctx, &identityv1.SetNicknameRequest{ + OrganizationId: orgID, + IdentityId: identityID, + Nickname: strings.Repeat("a", 33), + }) + requireStatusCode(t, err, codes.InvalidArgument) + + _, err = client.SetNickname(ctx, &identityv1.SetNicknameRequest{ + OrganizationId: orgID, + IdentityId: identityID, + Nickname: "Bad Name", + }) + requireStatusCode(t, err, codes.InvalidArgument) + + _, err = client.SetNickname(ctx, &identityv1.SetNicknameRequest{ + OrganizationId: orgID, + IdentityId: uuid.NewString(), + Nickname: "valid", + }) + requireStatusCode(t, err, codes.NotFound) + + _, err = client.RemoveNickname(ctx, &identityv1.RemoveNicknameRequest{ + OrganizationId: orgID, + IdentityId: identityID, + }) + requireStatusCode(t, err, codes.NotFound) }) }