@nakedev/go-scaffold 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +4 -0
  2. package/dist/commands/auth.js +19 -12
  3. package/dist/commands/method.js +2 -0
  4. package/dist/templates/rbac-manifest.js +1 -0
  5. package/dist/utils/auth-patcher.js +10 -1
  6. package/dist/utils/hexagonal-method-patcher.js +12 -1
  7. package/package.json +1 -1
  8. package/templates/add/auth/docs/schemas.yaml.hbs +10 -3
  9. package/templates/add/auth/docs/users-me-identity-local-link.yaml.hbs +16 -0
  10. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +7 -3
  11. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +2 -0
  12. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs +27 -0
  13. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +1 -0
  14. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +1 -1
  15. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +40 -17
  16. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +231 -74
  17. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +11 -8
  18. package/templates/add/auth/internal/app/user/application/errors.go.hbs +4 -0
  19. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +5 -5
  20. package/templates/add/auth/internal/app/user/application/identities.go.hbs +40 -2
  21. package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +33 -0
  22. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +3 -0
  23. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +1 -0
  24. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +2 -0
  25. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +3 -0
  26. package/templates/add/auth/internal/app/user/application/service.go.hbs +12 -0
  27. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +3 -3
  28. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +3 -0
  29. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +1 -0
  30. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +1 -0
  31. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +1 -1
  32. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +1 -0
  33. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +7 -1
  34. package/templates/add/auth/migrations/create_external_identities.down.sql.hbs +1 -0
  35. package/templates/add/auth/migrations/create_external_identities.up.sql.hbs +12 -0
  36. package/templates/add/auth/migrations/create_password_credentials.down.sql.hbs +1 -0
  37. package/templates/add/auth/migrations/create_password_credentials.up.sql.hbs +11 -0
  38. package/templates/add/auth/migrations/create_user_emails.down.sql.hbs +1 -0
  39. package/templates/add/auth/migrations/create_user_emails.up.sql.hbs +14 -0
  40. package/templates/add/auth/migrations/create_users.up.sql.hbs +0 -11
  41. package/templates/add/rbac/internal/app/role/adapters/inbound/http/dto.go.hbs +52 -0
  42. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +12 -11
  43. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +10 -10
  44. package/templates/create/base/AGENTS.md.hbs +7 -1
  45. package/templates/create/base/README.md.hbs +3 -1
  46. package/templates/add/auth/migrations/create_identities.down.sql.hbs +0 -1
  47. package/templates/add/auth/migrations/create_identities.up.sql.hbs +0 -15
@@ -24,6 +24,9 @@ func NewRecovery(repo ports.UserRepository, tokens ports.RecoveryTokenStore, has
24
24
  // ResetPassword consumes the token and updates the local identity in one
25
25
  // unit of work. The token adapter owns the transaction boundary.
26
26
  func (r *Recovery) ResetPassword(ctx context.Context, tokenHash, newPassword string) (uuid.UUID, error) {
27
+ if err := validatePassword(newPassword); err != nil {
28
+ return uuid.Nil, err
29
+ }
27
30
  hash, err := r.hasher.Hash(newPassword)
28
31
  if err != nil {
29
32
  return uuid.Nil, fmt.Errorf("hash password: %w", err)
@@ -92,6 +92,17 @@ func normalizeEmail(email string) string {
92
92
  return strings.ToLower(strings.TrimSpace(email))
93
93
  }
94
94
 
95
+ // bcrypt accepts at most 72 bytes. Enforce the same bound in the application
96
+ // layer so every caller (HTTP, seed, jobs, and future transports) gets the
97
+ // same safe contract instead of a provider-specific hashing failure.
98
+ func validatePassword(password string) error {
99
+ length := len([]byte(password))
100
+ if length < 8 || length > 72 {
101
+ return errInvalidPassword()
102
+ }
103
+ return nil
104
+ }
105
+
95
106
  // SetRole validates the role against the role catalog before persisting it.
96
107
  func (s *Service) SetRole(ctx context.Context, userID uuid.UUID, roleCode string) (*domain.User, error) {
97
108
  u, err := s.repo.FindByID(ctx, userID)
@@ -119,6 +130,7 @@ func (s *Service) SetRole(ctx context.Context, userID uuid.UUID, roleCode string
119
130
  // transports depend on this capability set instead of the concrete service.
120
131
  type ServicePort interface {
121
132
  ListIdentities(context.Context, uuid.UUID) ([]IdentityResponse, error)
133
+ LinkLocalIdentity(context.Context, uuid.UUID, string) error
122
134
  BeginIdentityLink(context.Context, uuid.UUID, string, LoginStartInput) (*Authorization, error)
123
135
  ExchangeIdentityLink(context.Context, uuid.UUID, string, LoginExchangeInput) (*IdentityResponse, error)
124
136
  UnlinkIdentity(context.Context, uuid.UUID, string) error
@@ -423,14 +423,14 @@ func (f *fakeRepo) ListIdentities(_ context.Context, userID uuid.UUID) ([]domain
423
423
  }
424
424
  return out, nil
425
425
  }
426
- func (f *fakeRepo) FindIdentityByProviderUID(_ context.Context, provider domain.Provider, providerUID string) (*domain.Identity, error) {
426
+ func (f *fakeRepo) FindIdentityByProviderUID(_ context.Context, issuer, providerUID string) (*domain.Identity, error) {
427
427
  for i := range f.identities {
428
- if f.identities[i].Provider == provider && f.identities[i].ProviderUID != nil && *f.identities[i].ProviderUID == providerUID {
428
+ if f.identities[i].Issuer == issuer && f.identities[i].ProviderUID != nil && *f.identities[i].ProviderUID == providerUID {
429
429
  copy := f.identities[i]
430
430
  return &copy, nil
431
431
  }
432
432
  }
433
- if f.identity != nil && f.identity.Provider == provider && f.identity.ProviderUID != nil && *f.identity.ProviderUID == providerUID {
433
+ if f.identity != nil && f.identity.Issuer == issuer && f.identity.ProviderUID != nil && *f.identity.ProviderUID == providerUID {
434
434
  copy := *f.identity
435
435
  return &copy, nil
436
436
  }
@@ -49,6 +49,9 @@ func (s *Service) EnsureUser(ctx context.Context, email, password, name string)
49
49
  } else if !errors.Is(err, domain.ErrNotFound) {
50
50
  return nil, fmt.Errorf("find existing user: %w", err)
51
51
  }
52
+ if err := validatePassword(password); err != nil {
53
+ return nil, err
54
+ }
52
55
 
53
56
  hash, err := s.passwords.Hash(password)
54
57
  if err != nil {
@@ -34,6 +34,7 @@ type Identity struct {
34
34
  ID uuid.UUID
35
35
  UserID uuid.UUID
36
36
  Provider Provider
37
+ Issuer string
37
38
  PasswordHash *string
38
39
  ProviderUID *string
39
40
  CreatedAt time.Time
@@ -6,6 +6,7 @@ var (
6
6
  ErrNotFound = errors.New("user not found")
7
7
  ErrConflict = errors.New("user conflict")
8
8
  ErrInvalidCredential = errors.New("invalid credentials")
9
+ ErrInvalidPassword = errors.New("password must be between 8 and 72 bytes")
9
10
  ErrInvalidToken = errors.New("invalid or expired token")
10
11
  ErrEmailTaken = errors.New("email already registered")
11
12
  ErrTooManyAttempts = errors.New("too many failed attempts")
@@ -17,7 +17,7 @@ type UserRepository interface {
17
17
  FindAll(context.Context, int, int) ([]domain.User, error)
18
18
  FindIdentity(context.Context, uuid.UUID, domain.Provider) (*domain.Identity, error)
19
19
  ListIdentities(context.Context, uuid.UUID) ([]domain.Identity, error)
20
- FindIdentityByProviderUID(context.Context, domain.Provider, string) (*domain.Identity, error)
20
+ FindIdentityByProviderUID(context.Context, string, string) (*domain.Identity, error)
21
21
  CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error
22
22
  CreateIdentity(context.Context, *domain.Identity) error
23
23
  UpdateIdentity(context.Context, *domain.Identity) error
@@ -217,6 +217,7 @@ func (p *Provider) Complete(ctx context.Context, in application.LoginCompleteInp
217
217
  }
218
218
  return application.ExternalIdentity{
219
219
  Provider: p.Name(),
220
+ Issuer: p.issuer,
220
221
  Subject: claims.Subject,
221
222
  Email: email,
222
223
  EmailVerified: info.EmailVerified || claims.EmailVerified,
@@ -26,7 +26,13 @@ func (r *RedisLimiter) Allow(ctx context.Context, key string, limit int, window
26
26
  return true
27
27
  }
28
28
  if count == 1 {
29
- r.rdb.Expire(ctx, redisKey, window)
29
+ if err := r.rdb.Expire(ctx, redisKey, window).Err(); err != nil {
30
+ // Never leave a counter without a TTL: an expired window must not
31
+ // become a permanent lockout after a transient Redis failure. The
32
+ // limiter is intentionally fail-open, so discard this best-effort
33
+ // counter and let the endpoint's own controls handle the request.
34
+ _ = r.rdb.Del(ctx, redisKey).Err()
35
+ }
30
36
  }
31
37
  return count <= int64(limit)
32
38
  }
@@ -0,0 +1 @@
1
+ DROP TABLE IF EXISTS user_svc.external_identities;
@@ -0,0 +1,12 @@
1
+ CREATE TABLE user_svc.external_identities (
2
+ id UUID PRIMARY KEY,
3
+ user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
4
+ provider VARCHAR(50) NOT NULL,
5
+ issuer TEXT NOT NULL,
6
+ subject TEXT NOT NULL,
7
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
8
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
9
+ );
10
+
11
+ CREATE UNIQUE INDEX idx_external_identities_issuer_subject ON user_svc.external_identities (issuer, subject);
12
+ CREATE UNIQUE INDEX idx_external_identities_user_provider ON user_svc.external_identities (user_id, provider);
@@ -0,0 +1 @@
1
+ DROP TABLE IF EXISTS user_svc.password_credentials;
@@ -0,0 +1,11 @@
1
+ CREATE TABLE user_svc.password_credentials (
2
+ id UUID PRIMARY KEY,
3
+ user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
4
+ password_hash TEXT NOT NULL,
5
+ hash_algorithm VARCHAR(32) NOT NULL DEFAULT 'bcrypt',
6
+ password_changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
7
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
8
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
9
+ );
10
+
11
+ CREATE UNIQUE INDEX idx_password_credentials_user ON user_svc.password_credentials (user_id);
@@ -0,0 +1 @@
1
+ DROP TABLE IF EXISTS user_svc.user_emails;
@@ -0,0 +1,14 @@
1
+ CREATE TABLE user_svc.user_emails (
2
+ id UUID PRIMARY KEY,
3
+ user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
4
+ email VARCHAR(255) NOT NULL,
5
+ email_normalized VARCHAR(255) NOT NULL,
6
+ is_primary BOOLEAN NOT NULL DEFAULT true,
7
+ verified_at TIMESTAMPTZ,
8
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
9
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
10
+ );
11
+
12
+ CREATE UNIQUE INDEX idx_user_emails_normalized ON user_svc.user_emails (email_normalized);
13
+ CREATE UNIQUE INDEX idx_user_emails_primary ON user_svc.user_emails (user_id) WHERE is_primary;
14
+ CREATE INDEX idx_user_emails_user ON user_svc.user_emails (user_id);
@@ -2,20 +2,9 @@ CREATE SCHEMA IF NOT EXISTS user_svc;
2
2
 
3
3
  CREATE TABLE user_svc.users (
4
4
  id UUID PRIMARY KEY,
5
- email VARCHAR(255) NOT NULL,
6
5
  name VARCHAR(255) NOT NULL DEFAULT '',
7
6
  avatar_url TEXT NOT NULL DEFAULT '',
8
- email_verified BOOLEAN NOT NULL DEFAULT false,
9
7
  role VARCHAR(20) NOT NULL DEFAULT 'staff',
10
8
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
11
9
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
12
10
  );
13
-
14
- -- A unique INDEX, not an inline UNIQUE constraint, and named to match the
15
- -- `uniqueIndex:idx_users_email` tag on the outbound persistence model exactly. Postgres names an
16
- -- inline UNIQUE "users_email_key"; the development bootstrap then sees
17
- -- uniqueness it didn't create and tries to drop it under its own
18
- -- name, which fails the boot with
19
- -- constraint "uni_users_email" of relation "users" does not exist
20
- -- for anyone who ran `make migrate-up` first — which `add rbac` tells you to.
21
- CREATE UNIQUE INDEX idx_users_email ON user_svc.users (email);
@@ -0,0 +1,52 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/role/application"
7
+ )
8
+
9
+ // Request DTOs belong to the inbound adapter. Keep JSON names and binding
10
+ // tags out of application inputs so role use cases remain transport-neutral.
11
+ type createInput struct {
12
+ Code string `json:"code"`
13
+ Name string `json:"name"`
14
+ }
15
+
16
+ func toCreateInput(in createInput) application.CreateInput {
17
+ return application.CreateInput{Code: in.Code, Name: in.Name}
18
+ }
19
+
20
+ type setPermissionsInput struct {
21
+ PermissionCodes *[]string `json:"permission_codes"`
22
+ }
23
+
24
+ func toSetPermissionsInput(in setPermissionsInput) application.SetPermissionsInput {
25
+ return application.SetPermissionsInput{PermissionCodes: in.PermissionCodes}
26
+ }
27
+
28
+ // Response DTOs belong to the inbound adapter too. Application responses are
29
+ // deliberately free of JSON tags and are mapped explicitly before encoding.
30
+ type roleResponse struct {
31
+ Code string `json:"code"`
32
+ Name string `json:"name"`
33
+ IsSystem bool `json:"is_system"`
34
+ Permissions []string `json:"permissions"`
35
+ CreatedAt time.Time `json:"created_at"`
36
+ }
37
+
38
+ func toRoleResponse(out application.RoleResponse) roleResponse {
39
+ return roleResponse{
40
+ Code: out.Code, Name: out.Name, IsSystem: out.IsSystem,
41
+ Permissions: out.Permissions, CreatedAt: out.CreatedAt,
42
+ }
43
+ }
44
+
45
+ type permissionResponse struct {
46
+ Code string `json:"code"`
47
+ Description string `json:"description"`
48
+ }
49
+
50
+ func toPermissionResponse(out application.PermissionResponse) permissionResponse {
51
+ return permissionResponse{Code: out.Code, Description: out.Description}
52
+ }
@@ -45,44 +45,45 @@ func (h *Handler) list(c *gin.Context) {
45
45
  c.Error(toHTTPError(err))
46
46
  return
47
47
  }
48
- out := make([]application.RoleResponse, len(items))
48
+ out := make([]roleResponse, len(items))
49
49
  for i := range items {
50
- out[i] = application.ToRoleResponse(items[i])
50
+ out[i] = toRoleResponse(application.ToRoleResponse(items[i]))
51
51
  }
52
52
  c.JSON(http.StatusOK, p.Response(out))
53
53
  }
54
54
 
55
55
  func (h *Handler) create(c *gin.Context) {
56
- var in application.CreateInput
56
+ var in createInput
57
57
  if err := c.ShouldBindJSON(&in); err != nil {
58
58
  c.Error(httpx.BindErr(err))
59
59
  return
60
60
  }
61
- item, err := h.svc.Create(c.Request.Context(), in)
61
+ item, err := h.svc.Create(c.Request.Context(), toCreateInput(in))
62
62
  if err != nil {
63
63
  c.Error(toHTTPError(err))
64
64
  return
65
65
  }
66
- c.JSON(http.StatusCreated, application.ToRoleResponse(*item))
66
+ c.JSON(http.StatusCreated, toRoleResponse(application.ToRoleResponse(*item)))
67
67
  }
68
68
 
69
69
  func (h *Handler) setPermissions(c *gin.Context) {
70
- var in application.SetPermissionsInput
70
+ var in setPermissionsInput
71
71
  if err := c.ShouldBindJSON(&in); err != nil {
72
72
  c.Error(httpx.BindErr(err))
73
73
  return
74
74
  }
75
- if in.PermissionCodes == nil {
75
+ appInput := toSetPermissionsInput(in)
76
+ if appInput.PermissionCodes == nil {
76
77
  c.Error(apperror.NewValidation("invalid role input", map[string]string{"permission_codes": "is required"}))
77
78
  return
78
79
  }
79
- item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), *in.PermissionCodes)
80
+ item, err := h.svc.SetPermissions(c.Request.Context(), c.Param("code"), *appInput.PermissionCodes)
80
81
  if err != nil {
81
82
  c.Error(toHTTPError(err))
82
83
  return
83
84
  }
84
85
  h.authz.Invalidate(item.Role.Code)
85
- c.JSON(http.StatusOK, application.ToRoleResponse(*item))
86
+ c.JSON(http.StatusOK, toRoleResponse(application.ToRoleResponse(*item)))
86
87
  }
87
88
 
88
89
  func (h *Handler) delete(c *gin.Context) {
@@ -101,9 +102,9 @@ func (h *Handler) listPermissions(c *gin.Context) {
101
102
  c.Error(toHTTPError(err))
102
103
  return
103
104
  }
104
- out := make([]application.PermissionResponse, len(items))
105
+ out := make([]permissionResponse, len(items))
105
106
  for i := range items {
106
- out[i] = application.ToPermissionResponse(items[i])
107
+ out[i] = toPermissionResponse(application.ToPermissionResponse(items[i]))
107
108
  }
108
109
  c.JSON(http.StatusOK, p.Response(out))
109
110
  }
@@ -9,12 +9,12 @@ import (
9
9
  const PermRoleManage = "role:manage"
10
10
 
11
11
  type CreateInput struct {
12
- Code string `json:"code"`
13
- Name string `json:"name"`
12
+ Code string
13
+ Name string
14
14
  }
15
15
 
16
16
  type SetPermissionsInput struct {
17
- PermissionCodes *[]string `json:"permission_codes"`
17
+ PermissionCodes *[]string
18
18
  }
19
19
 
20
20
  type RoleListItem struct {
@@ -23,16 +23,16 @@ type RoleListItem struct {
23
23
  }
24
24
 
25
25
  type RoleResponse struct {
26
- Code string `json:"code"`
27
- Name string `json:"name"`
28
- IsSystem bool `json:"is_system"`
29
- Permissions []string `json:"permissions"`
30
- CreatedAt time.Time `json:"created_at"`
26
+ Code string
27
+ Name string
28
+ IsSystem bool
29
+ Permissions []string
30
+ CreatedAt time.Time
31
31
  }
32
32
 
33
33
  type PermissionResponse struct {
34
- Code string `json:"code"`
35
- Description string `json:"description"`
34
+ Code string
35
+ Description string
36
36
  }
37
37
 
38
38
  func ToRoleResponse(item RoleListItem) RoleResponse {
@@ -187,13 +187,19 @@ approval before changing their contract.
187
187
  refresh tokens or token hashes. The current access token carries the session
188
188
  ID as `sid` so the adapter can mark the current device.
189
189
  - Authenticated users can manage login identities through
190
- `GET /users/me/identities`, `POST /users/me/identities/:provider/link`,
190
+ `GET /users/me/identities`, `POST /users/me/identities/local`, and
191
+ `POST /users/me/identities/:provider/link`,
191
192
  `POST /users/me/identities/:provider/link/exchange`, and
192
193
  `DELETE /users/me/identities/:provider`. The link transaction is bound to
193
194
  the current user as well as provider/state/PKCE/nonce; it can never create a
194
195
  session or silently move a provider account between users. Return only safe
195
196
  provider metadata, allow at most one identity per provider per user, and
196
197
  refuse to unlink the last remaining login method.
198
+ - Auth persistence keeps account profile, email addresses, password
199
+ credentials, and external provider identities in separate tables. Resolve an
200
+ external login by `(issuer, subject)`; never use an email address as the
201
+ provider identity key. Adding a password to a Google/OIDC account must write
202
+ a password credential for that existing user.
197
203
  - Password reset and email verification consume a one-time token in the same
198
204
  retry-safe transaction as the user/identity update. A failed post-commit
199
205
  session revocation must not make a successful reset impossible to retry.
@@ -201,7 +201,9 @@ timestamps are returned.
201
201
  They can also list safe login-provider metadata with
202
202
  `GET /users/me/identities`, link a provider through the authenticated
203
203
  `/users/me/identities/:provider/link` start/exchange flow, and unlink a
204
- provider with `DELETE /users/me/identities/:provider`; the last login method
204
+ provider with `DELETE /users/me/identities/:provider`. For a Google/OIDC-only
205
+ account, `POST /users/me/identities/local` adds an email/password credential to
206
+ the same account; it does not create a second user. The last login method
205
207
  cannot be removed.
206
208
  For `cross-site`, configure HTTPS origins, `COOKIE_SAMESITE=none`,
207
209
  `COOKIE_SECURE=true`, and an exact allowed Origin; the API applies an Origin
@@ -1 +0,0 @@
1
- DROP TABLE IF EXISTS user_svc.identities;
@@ -1,15 +0,0 @@
1
- CREATE TABLE user_svc.identities (
2
- id UUID PRIMARY KEY,
3
- user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
4
- provider VARCHAR(20) NOT NULL,
5
- password_hash TEXT,
6
- provider_uid TEXT,
7
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
8
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
9
- );
10
-
11
- -- Named unique indexes matching model.Identity's own
12
- -- `uniqueIndex:idx_identities_*` tags — see create_users.up.sql for why an
13
- -- anonymous UNIQUE constraint breaks schema parity.
14
- CREATE UNIQUE INDEX idx_identities_user_provider ON user_svc.identities (user_id, provider);
15
- CREATE UNIQUE INDEX idx_identities_provider_uid ON user_svc.identities (provider, provider_uid);