Skip to content

Commit 183f7b4

Browse files
authored
feat: implement Dex gRPC connector for identity service (#1351)
* feat: implement Dex gRPC connector for identity service Add services/identity/connector package providing a local PasswordConnector that validates credentials directly against the identity domain layer. - connector.go: implements Login() — resolves tenant from context, looks up identity by email, validates password via credentials.ValidatePassword, rejects locked/suspended/pending-invite accounts, queries active role assignments and returns them as Dex groups - claims.go: BuildClaims() constructs the JWT custom claims map (sub, email, name, x-tenant-id, roles, groups) consumed by Dex's token builder - connector_test.go / claims_test.go: 18 unit tests covering success path, wrong password, locked/suspended/pending-invite accounts, identity not found, repository errors, role resolution failure (non-fatal), missing tenant context, and claims structure The connector is decoupled from the Dex library — it defines its own PasswordConnector interface matching Dex's shape to avoid adding dexidp/dex as a Go module dependency. * fix: remove PII from connector log statements Remove email/username fields from the tenant-context-missing error log and the identity-not-found info log. tenant_id and identity_id are sufficient for debugging without logging PII. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 2766916 commit 183f7b4

4 files changed

Lines changed: 667 additions & 0 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package connector
2+
3+
import "github.com/meridianhub/meridian/shared/platform/tenant"
4+
5+
// BuildClaims constructs the JWT custom claims map for an authenticated identity.
6+
// The map is consumed by Dex's ID-token builder when enriching tokens with
7+
// connector-provided attributes.
8+
//
9+
// Claim keys follow the shared platform conventions:
10+
// - "sub" — stable user identifier (UUID)
11+
// - "email" — verified email address
12+
// - "name" — display name (falls back to email when not set)
13+
// - "x-tenant-id" — tenant identifier injected into every token for downstream routing
14+
// - "roles" — active role names; downstream services use this for RBAC
15+
// - "groups" — mirrors roles; included for compatibility with Dex's group claim handling
16+
func BuildClaims(identity Identity, tenantID tenant.TenantID) map[string]interface{} {
17+
name := identity.Username
18+
if name == "" {
19+
name = identity.Email
20+
}
21+
22+
roles := identity.Groups
23+
if roles == nil {
24+
roles = []string{}
25+
}
26+
27+
return map[string]interface{}{
28+
"sub": identity.UserID,
29+
"email": identity.Email,
30+
"name": name,
31+
tenant.TenantIDKey: tenantID.String(),
32+
"roles": roles,
33+
"groups": roles,
34+
}
35+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package connector_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/meridianhub/meridian/services/identity/connector"
7+
"github.com/meridianhub/meridian/shared/platform/tenant"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestBuildClaims_FullIdentity(t *testing.T) {
13+
tid, err := tenant.NewTenantID("volterra")
14+
require.NoError(t, err)
15+
16+
id := connector.Identity{
17+
UserID: "user-uuid-123",
18+
Username: "Alice",
19+
Email: "alice@example.com",
20+
Groups: []string{"ADMIN", "OPERATOR"},
21+
}
22+
23+
claims := connector.BuildClaims(id, tid)
24+
25+
assert.Equal(t, "user-uuid-123", claims["sub"])
26+
assert.Equal(t, "alice@example.com", claims["email"])
27+
assert.Equal(t, "Alice", claims["name"])
28+
assert.Equal(t, "volterra", claims["x-tenant-id"])
29+
assert.Equal(t, []string{"ADMIN", "OPERATOR"}, claims["roles"])
30+
assert.Equal(t, []string{"ADMIN", "OPERATOR"}, claims["groups"])
31+
}
32+
33+
func TestBuildClaims_EmptyUsernameDefaultsToEmail(t *testing.T) {
34+
tid, err := tenant.NewTenantID("acme")
35+
require.NoError(t, err)
36+
37+
id := connector.Identity{
38+
UserID: "user-uuid-456",
39+
Email: "bob@example.com",
40+
// Username intentionally empty
41+
}
42+
43+
claims := connector.BuildClaims(id, tid)
44+
45+
assert.Equal(t, "bob@example.com", claims["name"])
46+
}
47+
48+
func TestBuildClaims_NilGroupsProducesEmptySlice(t *testing.T) {
49+
tid, err := tenant.NewTenantID("demo")
50+
require.NoError(t, err)
51+
52+
id := connector.Identity{
53+
UserID: "user-uuid-789",
54+
Email: "carol@example.com",
55+
Groups: nil,
56+
}
57+
58+
claims := connector.BuildClaims(id, tid)
59+
60+
roles, ok := claims["roles"].([]string)
61+
require.True(t, ok)
62+
assert.Empty(t, roles)
63+
}
64+
65+
func TestBuildClaims_TenantIDPropagated(t *testing.T) {
66+
tid, err := tenant.NewTenantID("tenant_xyz")
67+
require.NoError(t, err)
68+
69+
id := connector.Identity{UserID: "u1", Email: "e@e.com"}
70+
71+
claims := connector.BuildClaims(id, tid)
72+
73+
assert.Equal(t, "tenant_xyz", claims["x-tenant-id"])
74+
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Package connector implements a local Dex password connector that validates
2+
// credentials directly against the identity domain layer without a network hop.
3+
//
4+
// Dex supports pluggable connectors via the connector.PasswordConnector interface.
5+
// Since Meridian runs Dex in the same process (or tightly coupled), this connector
6+
// bypasses HTTP/gRPC overhead by calling the domain repository directly.
7+
//
8+
// The connector:
9+
// - Resolves the tenant from context metadata (set by the gateway from subdomain)
10+
// - Looks up the identity by email within that tenant scope
11+
// - Validates the password using bcrypt via the credentials package
12+
// - Checks account status (locked, suspended, pending invite → reject)
13+
// - Queries active role assignments and maps them to Dex groups
14+
// - Returns a connector.Identity with groups populated for JWT claim injection
15+
package connector
16+
17+
import (
18+
"context"
19+
"errors"
20+
"fmt"
21+
"log/slog"
22+
"os"
23+
24+
"github.com/meridianhub/meridian/services/identity/domain"
25+
"github.com/meridianhub/meridian/shared/pkg/credentials"
26+
"github.com/meridianhub/meridian/shared/platform/tenant"
27+
)
28+
29+
// Identity represents the result of a successful authentication, compatible with
30+
// Dex's connector.Identity shape. Groups are populated with the identity's active roles
31+
// and are injected into the JWT as the "groups" claim by Dex.
32+
type Identity struct {
33+
// UserID is the stable identifier for the user (UUID string).
34+
UserID string
35+
// Username is the display name, defaulting to email if not set.
36+
Username string
37+
// Email is the verified email address.
38+
Email string
39+
// EmailVerified indicates whether the email has been verified.
40+
EmailVerified bool
41+
// Groups contains active role assignments, used to populate JWT group claims.
42+
Groups []string
43+
// ConnectorData is opaque bytes stored by Dex for refresh token support.
44+
ConnectorData []byte
45+
}
46+
47+
// LoginResult is returned by Login to convey both success state and the identity.
48+
type LoginResult struct {
49+
Identity Identity
50+
// Valid is true when authentication succeeded.
51+
Valid bool
52+
}
53+
54+
// PasswordConnector validates username/password credentials.
55+
// This interface mirrors Dex's connector.PasswordConnector to keep the implementation
56+
// decoupled from the Dex library (which is not a declared Go module dependency).
57+
type PasswordConnector interface {
58+
// Login validates credentials and returns the identity on success.
59+
// valid is false when credentials are incorrect without an underlying error.
60+
Login(ctx context.Context, scopes []string, username, password string) (identity Identity, valid bool, err error)
61+
}
62+
63+
// ErrRepositoryNil is returned by New when a nil repository is provided.
64+
var ErrRepositoryNil = errors.New("connector: repository must not be nil")
65+
66+
// Connector is the local implementation of PasswordConnector.
67+
// It performs credential validation and role resolution directly against the
68+
// identity domain repository, avoiding any network hop.
69+
type Connector struct {
70+
repo domain.Repository
71+
logger *slog.Logger
72+
}
73+
74+
// New creates a Connector with the given repository. If logger is nil a default
75+
// JSON logger writing to stdout is used.
76+
func New(repo domain.Repository, logger *slog.Logger) (*Connector, error) {
77+
if repo == nil {
78+
return nil, ErrRepositoryNil
79+
}
80+
if logger == nil {
81+
logger = slog.New(slog.NewJSONHandler(os.Stdout, nil))
82+
}
83+
return &Connector{repo: repo, logger: logger}, nil
84+
}
85+
86+
// Login validates the supplied username (email) and password against the identity
87+
// domain within the tenant derived from ctx.
88+
//
89+
// Returns:
90+
// - (identity, true, nil) on success
91+
// - (zero, false, nil) when credentials are simply wrong (no programming error)
92+
// - (zero, false, err) only for unexpected infrastructure errors
93+
func (c *Connector) Login(ctx context.Context, _ []string, username, password string) (Identity, bool, error) {
94+
tenantID, err := tenant.RequireFromContext(ctx)
95+
if err != nil {
96+
c.logger.ErrorContext(ctx, "connector: tenant context missing during login",
97+
"error", err)
98+
return Identity{}, false, fmt.Errorf("connector: %w", err)
99+
}
100+
101+
identity, err := c.repo.FindByEmail(ctx, username)
102+
if err != nil {
103+
if errors.Is(err, domain.ErrIdentityNotFound) {
104+
c.logger.InfoContext(ctx, "connector: identity not found",
105+
"tenant_id", tenantID)
106+
return Identity{}, false, nil
107+
}
108+
c.logger.ErrorContext(ctx, "connector: repository error looking up identity",
109+
"tenant_id", tenantID,
110+
"username", username,
111+
"error", err)
112+
return Identity{}, false, fmt.Errorf("connector: lookup identity: %w", err)
113+
}
114+
115+
// Reject non-active accounts before any password check.
116+
switch identity.Status() {
117+
case domain.IdentityStatusLocked:
118+
c.logger.InfoContext(ctx, "connector: login rejected — account locked",
119+
"tenant_id", tenantID,
120+
"identity_id", identity.ID())
121+
return Identity{}, false, nil
122+
case domain.IdentityStatusSuspended:
123+
c.logger.InfoContext(ctx, "connector: login rejected — account suspended",
124+
"tenant_id", tenantID,
125+
"identity_id", identity.ID())
126+
return Identity{}, false, nil
127+
case domain.IdentityStatusPendingInvite:
128+
c.logger.InfoContext(ctx, "connector: login rejected — account not yet activated",
129+
"tenant_id", tenantID,
130+
"identity_id", identity.ID())
131+
return Identity{}, false, nil
132+
case domain.IdentityStatusActive:
133+
// valid — proceed to password verification
134+
default:
135+
c.logger.WarnContext(ctx, "connector: login rejected — unknown account status",
136+
"tenant_id", tenantID,
137+
"identity_id", identity.ID(),
138+
"status", identity.Status())
139+
return Identity{}, false, nil
140+
}
141+
142+
if err := credentials.ValidatePassword(password, identity.PasswordHash()); err != nil {
143+
// Record the failed attempt; best-effort — do not surface save errors to caller.
144+
_ = identity.RecordLoginAttempt(false)
145+
if saveErr := c.repo.Save(ctx, identity); saveErr != nil {
146+
c.logger.ErrorContext(ctx, "connector: failed to persist failed login attempt",
147+
"identity_id", identity.ID(),
148+
"error", saveErr)
149+
}
150+
c.logger.InfoContext(ctx, "connector: invalid password",
151+
"tenant_id", tenantID,
152+
"identity_id", identity.ID())
153+
return Identity{}, false, nil
154+
}
155+
156+
// Record successful login; best-effort.
157+
_ = identity.RecordLoginAttempt(true)
158+
if saveErr := c.repo.Save(ctx, identity); saveErr != nil {
159+
c.logger.ErrorContext(ctx, "connector: failed to persist successful login",
160+
"identity_id", identity.ID(),
161+
"error", saveErr)
162+
}
163+
164+
// Resolve active role assignments → Dex groups.
165+
groups, err := c.activeRoles(ctx, identity)
166+
if err != nil {
167+
// Non-fatal: log and proceed with empty groups rather than denying login.
168+
c.logger.ErrorContext(ctx, "connector: failed to load role assignments",
169+
"identity_id", identity.ID(),
170+
"error", err)
171+
groups = []string{}
172+
}
173+
174+
connIdentity := Identity{
175+
UserID: identity.ID().String(),
176+
Username: identity.Email(),
177+
Email: identity.Email(),
178+
EmailVerified: true,
179+
Groups: groups,
180+
}
181+
182+
c.logger.InfoContext(ctx, "connector: login successful",
183+
"tenant_id", tenantID,
184+
"identity_id", identity.ID(),
185+
"roles", groups)
186+
187+
return connIdentity, true, nil
188+
}
189+
190+
// activeRoles returns the string role names for all non-revoked, non-expired
191+
// role assignments associated with the given identity.
192+
func (c *Connector) activeRoles(ctx context.Context, identity *domain.Identity) ([]string, error) {
193+
assignments, err := c.repo.FindRoleAssignments(ctx, identity.ID())
194+
if err != nil {
195+
return nil, fmt.Errorf("find role assignments: %w", err)
196+
}
197+
198+
roles := make([]string, 0, len(assignments))
199+
for _, a := range assignments {
200+
if a.IsActive() {
201+
roles = append(roles, string(a.Role()))
202+
}
203+
}
204+
return roles, nil
205+
}

0 commit comments

Comments
 (0)