Skip to content

Commit ce4b40b

Browse files
committed
Merge branch 'teleport/bind-clones-to-user' into 'master'
Bind Teleport clone access to authenticated user Closes #731 See merge request postgres-ai/database-lab!1163
2 parents 5509b87 + a49282c commit ce4b40b

25 files changed

Lines changed: 396 additions & 34 deletions

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

Lines changed: 66 additions & 1 deletion
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` | clone username | DB resources only, when known |
300+
| `dblab_user` | authenticated user (email local part) | 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`.
@@ -350,6 +350,71 @@ spec:
350350
> the sidecar does not set, add it with `--label writable=readwrite`, otherwise
351351
> access is denied.
352352

353+
## Per-User Clone Access
354+
355+
By default, every Teleport user who holds a role granting access to DBLab
356+
resources can connect to **any** clone. To make access per-user, the Engine can
357+
attach a trusted `dblab_user` label to each clone, derived from the
358+
**authenticated user's identity** rather than from any client-supplied value.
359+
360+
### Enable binding in server.yml
361+
362+
```yaml
363+
platform:
364+
enablePersonalTokens: true # required — identity comes from the personal token
365+
bindClonesToUser: true
366+
```
367+
368+
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.
374+
375+
Clones created with the shared `verificationToken` (for example CI pipelines or
376+
Joe) carry **no** `dblab_user` label. They are created normally — not rejected —
377+
but are not reachable through a per-user role that matches on `dblab_user`; grant
378+
those callers access through a broader role instead.
379+
380+
### Restrict access with a per-user role
381+
382+
```yaml
383+
kind: role
384+
version: v7
385+
metadata:
386+
name: dblab-self-access
387+
spec:
388+
allow:
389+
db_labels:
390+
dblab: ['true']
391+
dblab_user: ['{{email.local(external.email)}}']
392+
db_names: ['*']
393+
db_users: ['*']
394+
```
395+
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`.
398+
399+
### Limitations
400+
401+
- **Two identity sources must agree.** The label is computed from the email the
402+
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.
415+
- Clones created before binding was enabled, or with the shared token, have no
416+
`dblab_user` label; recreate them with a personal token to apply it.
417+
353418
## Connecting to a Clone
354419

355420
Once everything is running, users connect through Teleport:

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ func (s *service) reconcile(ctx context.Context) {
9797
continue
9898
}
9999

100-
res := dbResource{Name: name, Port: port, EnvID: s.cfg.EnvironmentID, CloneID: clone.ID, Username: clone.DB.Username}
100+
res := dbResource{Name: name, Port: port, EnvID: s.cfg.EnvironmentID, CloneID: clone.ID, OwnerUser: clone.DB.OwnerUser}
101101

102102
s.mu.Lock()
103103
err = createDB(ctx, s.cfg, res)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ type WebhookPayload struct {
6666
Port uint `json:"port,omitempty"`
6767
Username string `json:"username,omitempty"`
6868
DBName string `json:"dbname,omitempty"`
69+
OwnerUser string `json:"owner_user,omitempty"`
6970
ContainerName string `json:"container_name,omitempty"`
7071
}
7172

@@ -304,7 +305,7 @@ func (s *service) handleCloneCreate(ctx context.Context, p *WebhookPayload) erro
304305

305306
name := CloneServiceName(s.cfg.EnvironmentID, p.EntityID, int(p.Port))
306307

307-
res := dbResource{Name: name, Port: int(p.Port), EnvID: s.cfg.EnvironmentID, CloneID: p.EntityID, Username: p.Username}
308+
res := dbResource{Name: name, Port: int(p.Port), EnvID: s.cfg.EnvironmentID, CloneID: p.EntityID, OwnerUser: p.OwnerUser}
308309

309310
s.mu.Lock()
310311
err := createDB(ctx, s.cfg, res)

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -378,8 +378,8 @@ func TestParseListDBsOutput(t *testing.T) {
378378
}
379379

380380
func TestBuildDBYAML(t *testing.T) {
381-
t.Run("with username", func(t *testing.T) {
382-
res := dbResource{Name: "dblab-clone-prod-abc-5432", Port: 5432, EnvID: "prod", CloneID: "abc", Username: "testuser"}
381+
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"}
383383
yaml, err := buildDBYAML(res, nil)
384384
require.NoError(t, err)
385385
s := string(yaml)
@@ -392,8 +392,8 @@ func TestBuildDBYAML(t *testing.T) {
392392
assert.Contains(t, s, `uri: "127.0.0.1:5432"`)
393393
})
394394

395-
t.Run("without username", func(t *testing.T) {
396-
res := dbResource{Name: "dblab-clone-prod-abc-5432", Port: 5432, EnvID: "prod", CloneID: "abc", Username: ""}
395+
t.Run("without owner user", func(t *testing.T) {
396+
res := dbResource{Name: "dblab-clone-prod-abc-5432", Port: 5432, EnvID: "prod", CloneID: "abc", OwnerUser: ""}
397397
yaml, err := buildDBYAML(res, nil)
398398
require.NoError(t, err)
399399
s := string(yaml)
@@ -440,8 +440,8 @@ func TestBuildDBYAML(t *testing.T) {
440440
require.Error(t, err)
441441
})
442442

443-
t.Run("invalid username", func(t *testing.T) {
444-
res := dbResource{Name: "valid-name", Port: 5432, EnvID: "prod", CloneID: "abc", Username: "user\ninjection"}
443+
t.Run("invalid owner user", func(t *testing.T) {
444+
res := dbResource{Name: "valid-name", Port: 5432, EnvID: "prod", CloneID: "abc", OwnerUser: "user\ninjection"}
445445
_, err := buildDBYAML(res, nil)
446446
require.Error(t, err)
447447
})

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ func dbResourceLabels(res dbResource, custom map[string]string) map[string]strin
103103
labels := baseLabels(res.EnvID, custom)
104104
labels[labelCloneID] = res.CloneID
105105

106-
if res.Username != "" {
107-
labels[labelUser] = res.Username
106+
if res.OwnerUser != "" {
107+
labels[labelUser] = res.OwnerUser
108108
}
109109

110110
return labels
@@ -149,11 +149,11 @@ type tctlDB struct {
149149

150150
// dbResource holds parameters for creating a Teleport DB resource.
151151
type dbResource struct {
152-
Name string
153-
Port int
154-
EnvID string
155-
CloneID string
156-
Username string
152+
Name string
153+
Port int
154+
EnvID string
155+
CloneID string
156+
OwnerUser string
157157
}
158158

159159
func runTctl(ctx context.Context, tctlPath, identityFile, proxyAddr string, args ...string) ([]byte, error) {

engine/configs/config.example.ci_checker.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ platform:
4040
# If true: "verificationToken" is known only to admin, users use their own tokens,
4141
# and any token can be revoked not affecting others
4242
enablePersonalTokens: true
43+
# Label each clone with a trusted Teleport dblab_user tag from the authenticated
44+
# user's email local part; requires enablePersonalTokens. Shared-token clones
45+
# (e.g. CI) are left untagged rather than rejected; the Postgres username is unchanged.
46+
# bindClonesToUser: false
4347

4448

4549
source:

engine/configs/config.example.logical_generic.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,4 +211,5 @@ platform:
211211
# orgKey: "org_key" # Organization key
212212
# accessToken: "${PGAI_PLATFORM_ACCESS_TOKEN}" # Token for authorization in Platform API; get it at https://postgres.ai/console/YOUR_ORG_NAME/tokens
213213
# enablePersonalTokens: true # Enable authorization with personal tokens of the organization's members.
214+
# bindClonesToUser: false # Label each clone with a trusted Teleport dblab_user tag from the authenticated user's email local part (requires enablePersonalTokens); shared-token clones are left untagged and the clone's Postgres username is unchanged.
214215
#

engine/configs/config.example.logical_rds_iam.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,4 +202,5 @@ platform:
202202
# orgKey: "org_key" # Organization key
203203
# accessToken: "${PGAI_PLATFORM_ACCESS_TOKEN}" # Token for authorization in Platform API; get it at https://postgres.ai/console/YOUR_ORG_NAME/tokens
204204
# enablePersonalTokens: true # Enable authorization with personal tokens of the organization's members.
205+
# bindClonesToUser: false # Label each clone with a trusted Teleport dblab_user tag from the authenticated user's email local part (requires enablePersonalTokens); shared-token clones are left untagged and the clone's Postgres username is unchanged.
205206
#

engine/configs/config.example.physical_generic.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,5 @@ platform:
184184
# orgKey: "org_key" # Organization key
185185
# accessToken: "${PGAI_PLATFORM_ACCESS_TOKEN}" # Token for authorization in Platform API; get it at https://postgres.ai/console/YOUR_ORG_NAME/tokens
186186
# enablePersonalTokens: true # Enable authorization with personal tokens of the organization's members.
187+
# bindClonesToUser: false # Label each clone with a trusted Teleport dblab_user tag from the authenticated user's email local part (requires enablePersonalTokens); shared-token clones are left untagged and the clone's Postgres username is unchanged.
187188
#

engine/configs/config.example.physical_pgbackrest.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,4 +214,5 @@ platform:
214214
# orgKey: "org_key" # Organization key
215215
# accessToken: "${PGAI_PLATFORM_ACCESS_TOKEN}" # Token for authorization in Platform API; get it at https://postgres.ai/console/YOUR_ORG_NAME/tokens
216216
# enablePersonalTokens: true # Enable authorization with personal tokens of the organization's members.
217+
# bindClonesToUser: false # Label each clone with a trusted Teleport dblab_user tag from the authenticated user's email local part (requires enablePersonalTokens); shared-token clones are left untagged and the clone's Postgres username is unchanged.
217218
#

0 commit comments

Comments
 (0)