Skip to content

Commit 9fbebc7

Browse files
committed
Merge branch 'feature/731-forwarded-user-email' into 'master'
feat(auth): bind console-created clones to users via forwarded email Closes #731 See merge request postgres-ai/database-lab!1173
2 parents 4865f02 + c77f649 commit 9fbebc7

7 files changed

Lines changed: 182 additions & 69 deletions

File tree

engine/cmd/cli/commands/teleport/SETUP.md

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ supplied by the operator to match an existing Teleport taxonomy.
297297
| `dblab` | `"true"` | Marks the resource as DBLab-managed; used by the agent matcher and user roles |
298298
| `dblab_instance` | `<environment-id>` | Owning DBLab instance; used internally to keep reconciliation isolated per instance |
299299
| `clone_id` | clone ID | DB resources only |
300-
| `dblab_user` | authenticated user (email local part) | DB resources only, set when clone binding is enabled and the creator used a personal token |
300+
| `dblab_user` | authenticated user (full email address) | DB resources only, set when clone binding is enabled and the creator used a personal token |
301301
| `environment` | `<environment-id>` | Set **only** when no custom `environment` label is provided (backwards compatibility) |
302302

303303
> **Important:** Each DBLab instance must use a **unique** `--environment-id`.
@@ -366,17 +366,24 @@ platform:
366366
```
367367

368368
When `bindClonesToUser` is enabled, a clone created with a **personal per-user
369-
token** is labeled `dblab_user: <email local part>` — the part of the
370-
authenticated user's email before `@`, matching Teleport's
371-
`email.local(external.email)`. The clone's Postgres username (`db.username`) is
372-
**not** changed, so existing connection strings, Joe, and CI automation keep
373-
working.
369+
token** is labeled `dblab_user: <email>` — the authenticated user's full email
370+
address, matching Teleport's `external.email`. The clone's Postgres username
371+
(`db.username`) is **not** changed, so existing connection strings, Joe, and CI
372+
automation keep working.
374373

375374
Clones created with the shared `verificationToken` (for example CI pipelines or
376375
Joe) carry **no** `dblab_user` label. They are created normally — not rejected —
377376
but are not reachable through a per-user role that matches on `dblab_user`; grant
378377
those callers access through a broader role instead.
379378

379+
A trusted proxy that authenticates with the shared `verificationToken` on behalf
380+
of a known user (for example the PostgresAI Platform serving console requests)
381+
can assert the acting user by sending the `X-Forwarded-User-Email` header. The
382+
Engine then labels the clone exactly as if that user had used a personal token.
383+
The header is ignored on personal-token requests and when authorization is
384+
disabled; asserting an identity grants the caller nothing beyond what the shared
385+
token already allows.
386+
380387
### Restrict access with a per-user role
381388

382389
```yaml
@@ -388,32 +395,40 @@ spec:
388395
allow:
389396
db_labels:
390397
dblab: ['true']
391-
dblab_user: ['{{email.local(external.email)}}']
398+
dblab_user: ['{{external.email}}']
392399
db_names: ['*']
393400
db_users: ['*']
394401
```
395402

396-
With this role a user can reach only the clones labeled with their own email
397-
local part, e.g. `jsmith@acme.com` reaches resources labeled `dblab_user: jsmith`.
403+
With this role a user can reach only the clones labeled with their own email,
404+
e.g. `jsmith@acme.com` reaches resources labeled `dblab_user: jsmith@acme.com`.
398405

399406
### Limitations
400407

401-
- **Two identity sources must agree.** The label is computed from the email the
408+
- **Two identity sources must agree.** The label is the full email the
402409
PostgresAI Platform returns at clone-create time, while Teleport matches
403-
against `email.local(external.email)` from its own SSO/IdP at connect time.
404-
Both sides preserve case, and the `dblab_user` label is
405-
case-sensitive, so the two emails must match **exactly, including case**
406-
(`JSmith@acme.com` and `jsmith@acme.com` are different). If they differ, the
407-
user is denied access to their own clone (it fails closed). Make sure the
408-
Platform and the Teleport IdP emit the same address for each user.
409-
- Users whose email local parts collide across domains
410-
(`jsmith@acme.com` vs `jsmith@other.com`) map to the same `dblab_user` value.
411-
- Email local parts that cannot be represented as a Teleport label value
412-
(those containing `+` or other characters outside `[a-zA-Z0-9._-]`) cause the
413-
personal-token clone-create request to be rejected rather than silently
414-
rewritten, so the Engine label always equals Teleport's computed value.
410+
against `external.email` from its own SSO/IdP at connect time. Both sides
411+
preserve case, and the `dblab_user` label is case-sensitive, so the two emails
412+
must match **exactly, including case** (`JSmith@acme.com` and `jsmith@acme.com`
413+
are different). If they differ, the user is denied access to their own clone
414+
(it fails closed). Make sure the Platform and the Teleport IdP emit the same
415+
address for each user. This includes the **domain**: because the full address
416+
is compared, a source that normalizes the domain case on only one side
417+
(`user@Acme.io` vs `user@acme.io`) also fails closed, even though DNS treats
418+
the two as the same mailbox.
419+
- Email addresses that cannot be represented as a Teleport label value
420+
(those containing `+` or other characters outside `[a-zA-Z0-9._@-]`) produce an
421+
**unlabeled** clone with a warning in the Engine log — the label is never
422+
silently rewritten, so when a label is present it always equals Teleport's
423+
value. Such users need a broader role to reach their clones.
415424
- Clones created before binding was enabled, or with the shared token, have no
416425
`dblab_user` label; recreate them with a personal token to apply it.
426+
- Upgrading from a build that labeled clones with the email **local part**:
427+
existing Teleport `db` resources keep their old `dblab_user: <local part>`
428+
label — reconciliation matches resources by name and does not relabel them —
429+
so once the role matches on `{{external.email}}` those users lose access.
430+
Remove the affected `db` resources (or recreate the clones) so the sidecar
431+
re-registers them with the full-email label.
417432

418433
## Connecting to a clone
419434

engine/cmd/cli/commands/teleport/serve_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ func TestParseListDBsOutput(t *testing.T) {
379379

380380
func TestBuildDBYAML(t *testing.T) {
381381
t.Run("with owner user", func(t *testing.T) {
382-
res := dbResource{Name: "dblab-clone-prod-abc-5432", Port: 5432, EnvID: "prod", CloneID: "abc", OwnerUser: "testuser"}
382+
res := dbResource{Name: "dblab-clone-prod-abc-5432", Port: 5432, EnvID: "prod", CloneID: "abc", OwnerUser: "jsmith@acme.io"}
383383
yaml, err := buildDBYAML(res, nil)
384384
require.NoError(t, err)
385385
s := string(yaml)
@@ -388,7 +388,7 @@ func TestBuildDBYAML(t *testing.T) {
388388
assert.Contains(t, s, `dblab_instance: "prod"`)
389389
assert.Contains(t, s, `environment: "prod"`)
390390
assert.Contains(t, s, `clone_id: "abc"`)
391-
assert.Contains(t, s, `dblab_user: "testuser"`)
391+
assert.Contains(t, s, `dblab_user: "jsmith@acme.io"`)
392392
assert.Contains(t, s, `uri: "127.0.0.1:5432"`)
393393
})
394394

engine/internal/srv/mw/auth.go

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import (
1818
// VerificationTokenHeader defines the verification token name that should be passed in request headers.
1919
const VerificationTokenHeader = "Verification-Token"
2020

21+
// ForwardedUserEmailHeader carries the acting user's email asserted by a trusted caller
22+
// authenticated with the shared verification token (e.g. the Platform proxying console
23+
// requests). It is ignored on personal-token requests and when authorization is disabled.
24+
const ForwardedUserEmailHeader = "X-Forwarded-User-Email"
25+
2126
// wsTokenKey defines the name of web-sockets token parameter that should be passed in query string.
2227
const wsTokenKey = "token"
2328

@@ -42,7 +47,7 @@ func NewAuth(verificationToken string, personalTokenVerifier platform.PersonalTo
4247
// resolved user identity (for personal tokens) to the request context.
4348
func (a *Auth) Authorized(h http.HandlerFunc) http.HandlerFunc {
4449
return func(w http.ResponseWriter, r *http.Request) {
45-
ctx, ok := a.authenticate(r.Context(), r.Header.Get(VerificationTokenHeader))
50+
ctx, ok := a.authenticate(r.Context(), r.Header.Get(VerificationTokenHeader), r.Header.Get(ForwardedUserEmailHeader))
4651
if !ok {
4752
api.SendUnauthorizedError(w, r)
4853
return
@@ -67,32 +72,49 @@ func (a *Auth) AdminMW(h http.Handler) http.Handler {
6772
}
6873

6974
func (a *Auth) isAccessAllowed(ctx context.Context, token string) bool {
70-
_, ok := a.authenticate(ctx, token)
75+
_, ok := a.authenticate(ctx, token, "")
7176

7277
return ok
7378
}
7479

7580
// authenticate validates the token and, for a valid personal token, returns a
76-
// context carrying the resolved user identity. The shared verification token is
77-
// authorized but carries no identity.
78-
func (a *Auth) authenticate(ctx context.Context, token string) (context.Context, bool) {
81+
// context carrying the resolved user identity. The shared verification token
82+
// carries no identity of its own, but a shared-token caller may assert the
83+
// acting user via forwardedEmail; the assertion is trusted because the shared
84+
// token already grants full instance access.
85+
func (a *Auth) authenticate(ctx context.Context, token, forwardedEmail string) (context.Context, bool) {
7986
if a.verificationToken == "" {
8087
return ctx, true
8188
}
8289

8390
if subtle.ConstantTimeCompare([]byte(a.verificationToken), []byte(token)) == 1 {
84-
return ctx, true
91+
return withForwardedIdentity(ctx, forwardedEmail), true
8592
}
8693

8794
if a.personalTokenVerifier != nil && a.personalTokenVerifier.IsPersonalTokenEnabled() {
8895
if identity, ok := a.personalTokenVerifier.AuthenticatePersonalToken(ctx, token); ok {
89-
return context.WithValue(ctx, userIdentityKey, identity), true
96+
return WithUserIdentity(ctx, identity), true
9097
}
9198
}
9299

93100
return ctx, false
94101
}
95102

103+
// withForwardedIdentity attaches the identity asserted by a shared-token caller.
104+
func withForwardedIdentity(ctx context.Context, email string) context.Context {
105+
if email == "" {
106+
return ctx
107+
}
108+
109+
return WithUserIdentity(ctx, platform.UserIdentity{Email: email})
110+
}
111+
112+
// WithUserIdentity returns a context carrying the given user identity, as
113+
// attached by the middleware for personal-token and forwarded identities.
114+
func WithUserIdentity(ctx context.Context, identity platform.UserIdentity) context.Context {
115+
return context.WithValue(ctx, userIdentityKey, identity)
116+
}
117+
96118
// UserIdentityFromContext returns the authenticated user identity attached to
97119
// the context by Authorized, if present.
98120
func UserIdentityFromContext(ctx context.Context) (platform.UserIdentity, bool) {

engine/internal/srv/mw/auth_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,57 @@ func TestAuthorized_UserIdentity(t *testing.T) {
170170
}
171171
}
172172

173+
func TestAuthorized_ForwardedIdentity(t *testing.T) {
174+
testCases := []struct {
175+
name string
176+
verificationToken string
177+
token string
178+
forwardedEmail string
179+
wantStatus int
180+
wantOK bool
181+
wantEmail string
182+
}{
183+
{name: "shared token with forwarded email attaches identity", verificationToken: testVerificationToken,
184+
token: testVerificationToken, forwardedEmail: "console@acme.io", wantStatus: http.StatusOK, wantOK: true, wantEmail: "console@acme.io"},
185+
{name: "shared token without forwarded email has no identity", verificationToken: testVerificationToken,
186+
token: testVerificationToken, wantStatus: http.StatusOK},
187+
{name: "personal token ignores forwarded email", verificationToken: testVerificationToken,
188+
token: testPlatformAccessToken, forwardedEmail: "spoofed@acme.io", wantStatus: http.StatusOK, wantOK: true, wantEmail: "u@acme.io"},
189+
{name: "wrong token with forwarded email is unauthorized", verificationToken: testVerificationToken,
190+
token: "wrong", forwardedEmail: "console@acme.io", wantStatus: http.StatusUnauthorized},
191+
{name: "disabled authorization ignores forwarded email", verificationToken: "",
192+
token: "", forwardedEmail: "console@acme.io", wantStatus: http.StatusOK},
193+
}
194+
195+
for _, tc := range testCases {
196+
t.Run(tc.name, func(t *testing.T) {
197+
var gotIdentity platform.UserIdentity
198+
199+
var gotOK bool
200+
201+
auth := NewAuth(tc.verificationToken, MockPersonalTokenVerifier{isPersonalTokenEnabled: true, email: "u@acme.io"})
202+
handler := auth.Authorized(func(w http.ResponseWriter, r *http.Request) {
203+
gotIdentity, gotOK = UserIdentityFromContext(r.Context())
204+
w.WriteHeader(http.StatusOK)
205+
})
206+
207+
req := httptest.NewRequest(http.MethodGet, "/clone", nil)
208+
req.Header.Set(VerificationTokenHeader, tc.token)
209+
210+
if tc.forwardedEmail != "" {
211+
req.Header.Set(ForwardedUserEmailHeader, tc.forwardedEmail)
212+
}
213+
214+
rec := httptest.NewRecorder()
215+
handler(rec, req)
216+
217+
require.Equal(t, tc.wantStatus, rec.Code)
218+
assert.Equal(t, tc.wantOK, gotOK)
219+
assert.Equal(t, tc.wantEmail, gotIdentity.Email)
220+
})
221+
}
222+
}
223+
173224
func TestAdminMW(t *testing.T) {
174225
okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
175226
w.WriteHeader(http.StatusOK)

engine/internal/srv/routes.go

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -631,13 +631,7 @@ func (s *Server) createClone(w http.ResponseWriter, r *http.Request) {
631631
}
632632

633633
if s.Platform != nil && s.Platform.BindClonesToUser() {
634-
owner, err := ownerFromContext(r.Context())
635-
if err != nil {
636-
api.SendBadRequestError(w, r, err.Error())
637-
return
638-
}
639-
640-
cloneRequest.DB.OwnerUser = owner
634+
cloneRequest.DB.OwnerUser = ownerFromContext(r.Context())
641635
}
642636

643637
if cloneRequest.Snapshot != nil && cloneRequest.Snapshot.ID != "" {
@@ -728,53 +722,63 @@ func (s *Server) createClone(w http.ResponseWriter, r *http.Request) {
728722
log.Dbg(fmt.Sprintf("Clone ID=%s is being created", newClone.ID))
729723
}
730724

731-
// maxOwnerLabelLength caps the derived owner label length.
732-
const maxOwnerLabelLength = 63
725+
// maxOwnerLabelLength caps the derived owner label length; RFC 5321 limits an
726+
// email address to 254 characters.
727+
const maxOwnerLabelLength = 254
733728

734729
// safeOwnerLabel restricts the derived owner label to characters valid as a
735730
// Teleport label value (a subset of the sidecar's safeYAMLValue), so the engine
736-
// label always matches Teleport's computed email.local value.
737-
var safeOwnerLabel = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
731+
// label always matches Teleport's external.email value.
732+
var safeOwnerLabel = regexp.MustCompile(`^[a-zA-Z0-9._@-]+$`)
738733

739734
// ownerFromContext resolves the clone owner label from the authenticated user
740-
// identity on the context. It returns an empty string and no error when no
741-
// identity is present, so shared-token callers create unlabeled clones. A
742-
// resolved personal-token identity with an empty email is logged as a likely
743-
// misconfiguration (the Platform is not returning the user email) and also
744-
// yields an unlabeled clone. It errors only when an identity is present with an
745-
// email that cannot be represented as a valid owner label.
746-
func ownerFromContext(ctx context.Context) (string, error) {
735+
// identity on the context. It returns an empty string when no identity is
736+
// present, when the identity has no email, or when the email cannot be
737+
// represented as a valid owner label — such requests create unlabeled clones
738+
// instead of failing; the fallback is logged as a warning so a misconfiguration
739+
// (the Platform not returning the email) or an unlabelable address (e.g. a
740+
// plus-tagged local part) stays visible without blocking clone creation.
741+
func ownerFromContext(ctx context.Context) string {
747742
identity, ok := mw.UserIdentityFromContext(ctx)
748743
if !ok {
749-
return "", nil
744+
return ""
750745
}
751746

752747
if identity.Email == "" {
753748
log.Warn("clone-to-user binding is enabled but the authenticated identity has no email; " +
754749
"creating an unlabeled clone (check that the Platform returns the user email)")
755750

756-
return "", nil
751+
return ""
752+
}
753+
754+
owner, err := ownerFromEmail(identity.Email)
755+
if err != nil {
756+
log.Warn(fmt.Sprintf("clone-to-user binding is enabled but no owner label can be derived: %v; "+
757+
"creating an unlabeled clone", err))
758+
759+
return ""
757760
}
758761

759-
return ownerFromEmail(identity.Email)
762+
return owner
760763
}
761764

762-
// ownerFromEmail extracts the email local part exactly as Teleport's
763-
// email.local(external.email) does — mail.ParseAddress, then the part before the
764-
// first "@" — and validates that it is usable as a Teleport label value, so the
765-
// engine label always matches Teleport's value.
765+
// ownerFromEmail normalizes the email exactly as Teleport's external.email trait
766+
// carries it — mail.ParseAddress strips any display name and surrounding
767+
// whitespace — and validates that the full address is usable as a Teleport label
768+
// value, so the engine label always matches Teleport's external.email value. The
769+
// full address is used (not just the local part) so users whose local parts
770+
// collide across domains still map to distinct labels.
766771
func ownerFromEmail(email string) (string, error) {
767772
addr, err := mail.ParseAddress(email)
768773
if err != nil {
769774
return "", fmt.Errorf("cannot parse email %q: %w", email, err)
770775
}
771776

772-
local, _, _ := strings.Cut(addr.Address, "@")
773-
if !isValidOwnerLabel(local) {
777+
if !isValidOwnerLabel(addr.Address) {
774778
return "", fmt.Errorf("cannot derive a valid owner label from email %q", email)
775779
}
776780

777-
return local, nil
781+
return addr.Address, nil
778782
}
779783

780784
// isValidOwnerLabel reports whether name is usable as a Teleport dblab_user

0 commit comments

Comments
 (0)