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
100 changes: 100 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Comment thread
noa-lucent marked this conversation as resolved.
}
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, &notFound) {
Expand Down
73 changes: 73 additions & 0 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
Expand Down Expand Up @@ -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
}
15 changes: 15 additions & 0 deletions migrations/0002_org_nicknames.sql
Original file line number Diff line number Diff line change
@@ -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)
);
Comment thread
noa-lucent marked this conversation as resolved.

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;
151 changes: 151 additions & 0 deletions test/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package e2e

import (
"context"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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) {
Comment thread
noa-lucent marked this conversation as resolved.
_, err := client.GetIdentityType(ctx, &identityv1.GetIdentityTypeRequest{IdentityId: uuid.NewString()})
requireStatusCode(t, err, codes.NotFound)
Expand All @@ -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)
})
}

Expand Down
Loading