Skip to content

Commit eb8b027

Browse files
committed
feat(multi-user): enforce owner-scoped visibility and session guards
1 parent 68d74b2 commit eb8b027

15 files changed

Lines changed: 146 additions & 24 deletions

docs/command-reference.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- `-c, --config`: config file path
66
- `--session`: session name override
7+
- `--owner`: owner identity override (default: `OKDEV_OWNER` or local `USER`)
78
- `-n, --namespace`: namespace override
89
- `--context`: kube context override
910
- `--output text|json`: output format for list/status
@@ -17,11 +18,11 @@
1718
- `okdev up [--no-attach] [--wait-timeout 3m] [--dry-run]`
1819
- attach is enabled by default; use `--no-attach` to skip shell + background integrations
1920
- `okdev down [--delete-pvc] [--dry-run]`
20-
- `okdev status [--all]`
21-
- `okdev list [--all-namespaces]`
21+
- `okdev status [--all] [--all-users]`
22+
- `okdev list [--all-namespaces] [--all-users]`
2223
- `okdev use <session>`
2324
- `okdev connect [--shell /bin/bash] [--cmd "..."] [--no-tty]`
2425
- `okdev ssh [--setup-key] [--user root] [--cmd "..."]`
2526
- `okdev ports`
2627
- `okdev sync [--mode up|down|bi] [--engine native|syncthing] [--watch] [--background] [--dry-run] [--force]`
27-
- `okdev prune [--ttl-hours 72] [--all-namespaces] [--include-pvc] [--dry-run]`
28+
- `okdev prune [--ttl-hours 72] [--all-namespaces] [--all-users] [--include-pvc] [--dry-run]`

docs/okdev-design.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,9 +250,11 @@ Ownership model:
250250

251251
- `okdev status`
252252
- show pod state, sync health, forwarded ports, idle timer
253+
- defaults to current owner visibility; `--all-users` required to include other owners
253254

254255
- `okdev list`
255-
- list all sessions in current namespace/context (or across namespaces with flag)
256+
- list sessions in current namespace/context (or across namespaces with flag)
257+
- defaults to current owner visibility; `--all-users` required to include other owners
256258
- includes repo, branch, owner, age, status, and lock mode
257259

258260
- `okdev use <session>`
@@ -265,7 +267,7 @@ Ownership model:
265267
- supports `--dry-run` to preview deletions
266268

267269
- `okdev prune [--dry-run]`
268-
- cleanup expired/idle sessions by TTL rules
270+
- cleanup expired/idle sessions by TTL rules (owner-scoped by default)
269271
- enforces idle timeout from heartbeat (`okdev.io/last-attach`)
270272

271273
---
@@ -280,10 +282,15 @@ okdev approach:
280282
- Any machine with kube access and repo can run `okdev up --attach`.
281283

282284
Optional lock modes:
283-
- `none` (default shared)
285+
- `none`
284286
- `advisory` (warn if another active client)
285287
- `exclusive` (single active client lease with timeout)
286288

289+
Ownership model:
290+
- Owner identity resolution: `--owner` flag -> `OKDEV_OWNER` -> local `USER`.
291+
- Sessions are labeled with `okdev.io/owner=<owner>`.
292+
- Mutating commands refuse non-owner sessions unless session is explicitly marked shareable.
293+
287294
Lease behavior:
288295
- Lease duration is short-lived (default 2 minutes) and renewed in background for long-running commands (`connect`, `ssh`, `ports`, `sync --watch`, `sync --engine syncthing`, and `up --attach`).
289296
- `okdev down` deletes the Lease object so exclusive sessions can be reacquired immediately.

docs/quickstart.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ Preview-only mode (no cluster changes):
7272
./bin/okdev list
7373
./bin/okdev use serving-main-alice
7474
./bin/okdev status --all
75+
# cross-owner visibility only when explicitly requested
76+
./bin/okdev list --all-users
7577
```
7678

7779
## Stop and cleanup

internal/cli/common.go

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"os/exec"
1111
"path/filepath"
12+
"regexp"
1213
"strings"
1314
"time"
1415

@@ -21,6 +22,8 @@ import (
2122
const sessionLeaseDuration = 2 * time.Minute
2223
const sessionHeartbeatInterval = 5 * time.Minute
2324

25+
var invalidOwnerChars = regexp.MustCompile(`[^a-z0-9._-]`)
26+
2427
func loadConfigAndNamespace(opts *Options) (*config.DevEnvironment, string, error) {
2528
cfg, path, err := config.Load(opts.ConfigPath)
2629
if err != nil {
@@ -48,11 +51,8 @@ func resolveSessionName(opts *Options, cfg *config.DevEnvironment) (string, erro
4851
return session.Resolve(opts.Session, cfg.Spec.Session.DefaultNameTemplate)
4952
}
5053

51-
func labelsForSession(cfg *config.DevEnvironment, sessionName string) map[string]string {
52-
owner := os.Getenv("USER")
53-
if owner == "" {
54-
owner = "dev"
55-
}
54+
func labelsForSession(opts *Options, cfg *config.DevEnvironment, sessionName string) map[string]string {
55+
owner := currentOwner(opts)
5656
repo := "unknown"
5757
if root, err := session.RepoRoot(); err == nil && root != "" {
5858
repo = filepath.Base(root)
@@ -63,12 +63,24 @@ func labelsForSession(cfg *config.DevEnvironment, sessionName string) map[string
6363
"okdev.io/session": sessionName,
6464
"okdev.io/owner": owner,
6565
"okdev.io/repo": repo,
66+
"okdev.io/shareable": func() string {
67+
if cfg.Spec.Session.Shareable {
68+
return "true"
69+
}
70+
return "false"
71+
}(),
6672
}
6773
}
6874

6975
func annotationsForSession(cfg *config.DevEnvironment) map[string]string {
7076
out := map[string]string{
7177
"okdev.io/last-attach": time.Now().UTC().Format(time.RFC3339),
78+
"okdev.io/shareable": func() string {
79+
if cfg.Spec.Session.Shareable {
80+
return "true"
81+
}
82+
return "false"
83+
}(),
7284
}
7385
if cfg.Spec.Session.TTLHours > 0 {
7486
out["okdev.io/ttl-hours"] = fmt.Sprintf("%d", cfg.Spec.Session.TTLHours)
@@ -252,17 +264,69 @@ func startSessionMaintenanceWithClient(k *kube.Client, cfg *config.DevEnvironmen
252264
}
253265

254266
func sessionHolderIdentity() string {
255-
user := os.Getenv("USER")
256-
if user == "" {
257-
user = "dev"
258-
}
267+
user := currentOwner(nil)
259268
host, err := os.Hostname()
260269
if err != nil || host == "" {
261270
host = "unknown-host"
262271
}
263272
return user + "@" + host
264273
}
265274

275+
func currentOwner(opts *Options) string {
276+
if opts != nil {
277+
if v := normalizeOwner(opts.Owner); v != "" {
278+
return v
279+
}
280+
}
281+
if v := normalizeOwner(os.Getenv("OKDEV_OWNER")); v != "" {
282+
return v
283+
}
284+
if v := normalizeOwner(os.Getenv("USER")); v != "" {
285+
return v
286+
}
287+
return "dev"
288+
}
289+
290+
func normalizeOwner(v string) string {
291+
s := strings.ToLower(strings.TrimSpace(v))
292+
s = strings.ReplaceAll(s, " ", "-")
293+
s = invalidOwnerChars.ReplaceAllString(s, "-")
294+
s = strings.Trim(s, "-")
295+
return s
296+
}
297+
298+
func ownerLabelSelector(opts *Options) string {
299+
return "okdev.io/owner=" + currentOwner(opts)
300+
}
301+
302+
func isSessionShareable(p kube.PodSummary) bool {
303+
if strings.EqualFold(strings.TrimSpace(p.Annotations["okdev.io/shareable"]), "true") {
304+
return true
305+
}
306+
return strings.EqualFold(strings.TrimSpace(p.Labels["okdev.io/shareable"]), "true")
307+
}
308+
309+
func ensureSessionOwnership(opts *Options, k *kube.Client, namespace, sessionName string, allowShareable bool) error {
310+
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
311+
defer cancel()
312+
pods, err := k.ListPods(ctx, namespace, false, "okdev.io/managed=true,okdev.io/session="+sessionName)
313+
if err != nil {
314+
return err
315+
}
316+
if len(pods) == 0 {
317+
return nil
318+
}
319+
owner := currentOwner(opts)
320+
otherOwner := strings.TrimSpace(pods[0].Labels["okdev.io/owner"])
321+
if otherOwner == "" || otherOwner == owner {
322+
return nil
323+
}
324+
if allowShareable && isSessionShareable(pods[0]) {
325+
return nil
326+
}
327+
return fmt.Errorf("session %q is owned by %q (current owner: %q); set --owner %s or mark session as shareable", sessionName, otherOwner, owner, otherOwner)
328+
}
329+
266330
func ensureCommand(name string) error {
267331
if _, err := exec.LookPath(name); err != nil {
268332
return fmt.Errorf("required command %q not found in PATH", name)

internal/cli/common_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ import (
1111
func TestNamesAndLabels(t *testing.T) {
1212
t.Setenv("USER", "alice")
1313
cfg := &config.DevEnvironment{Metadata: config.Metadata{Name: "proj"}}
14-
labels := labelsForSession(cfg, "sess1")
14+
labels := labelsForSession(&Options{}, cfg, "sess1")
1515
if labels["okdev.io/session"] != "sess1" {
1616
t.Fatalf("session label mismatch: %+v", labels)
1717
}
1818
if labels["okdev.io/owner"] != "alice" {
1919
t.Fatalf("owner label mismatch: %+v", labels)
2020
}
21+
if labels["okdev.io/shareable"] != "false" {
22+
t.Fatalf("shareable label mismatch: %+v", labels)
23+
}
2124
if podName("sess1") != "okdev-sess1" {
2225
t.Fatalf("unexpected pod name %q", podName("sess1"))
2326
}

internal/cli/connect.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ func newConnectCmd(opts *Options) *cobra.Command {
2323
if err != nil {
2424
return err
2525
}
26+
k := newKubeClient(opts)
27+
if err := ensureSessionOwnership(opts, k, ns, sn, true); err != nil {
28+
return err
29+
}
2630
stopRenew, err := acquireSessionLock(opts, cfg, ns, sn, cmd.OutOrStdout())
2731
if err != nil {
2832
return err
@@ -44,7 +48,7 @@ func newConnectCmd(opts *Options) *cobra.Command {
4448
if len(execCmd) == 1 && strings.TrimSpace(execCmd[0]) == "" {
4549
execCmd = []string{"sh", "-lc", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
4650
}
47-
return runConnect(opts, ns, sn, execCmd, !noTTY)
51+
return runConnectWithClient(k, ns, sn, execCmd, !noTTY)
4852
},
4953
}
5054
cmd.Flags().StringVar(&shell, "shell", "", "Shell to start (default auto-detects bash/sh)")

internal/cli/down.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ func newDownCmd(opts *Options) *cobra.Command {
2323
return err
2424
}
2525
k := newKubeClient(opts)
26+
if err := ensureSessionOwnership(opts, k, ns, sn, false); err != nil {
27+
return err
28+
}
2629
ctx, cancel := defaultContext()
2730
defer cancel()
2831
if dryRun {

internal/cli/list.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
func newListCmd(opts *Options) *cobra.Command {
1313
var allNamespaces bool
14+
var allUsers bool
1415

1516
cmd := &cobra.Command{
1617
Use: "list",
@@ -32,7 +33,11 @@ func newListCmd(opts *Options) *cobra.Command {
3233
}
3334
ctx, cancel := defaultContext()
3435
defer cancel()
35-
pods, err := newKubeClient(opts).ListPods(ctx, ns, allNamespaces, "okdev.io/managed=true")
36+
label := "okdev.io/managed=true"
37+
if !allUsers {
38+
label = label + "," + ownerLabelSelector(opts)
39+
}
40+
pods, err := newKubeClient(opts).ListPods(ctx, ns, allNamespaces, label)
3641
if err != nil {
3742
return err
3843
}
@@ -43,6 +48,7 @@ func newListCmd(opts *Options) *cobra.Command {
4348
type listRow struct {
4449
Namespace string `json:"namespace"`
4550
Session string `json:"session"`
51+
Owner string `json:"owner"`
4652
Phase string `json:"phase"`
4753
Age string `json:"age"`
4854
Active bool `json:"active"`
@@ -53,6 +59,7 @@ func newListCmd(opts *Options) *cobra.Command {
5359
rows = append(rows, listRow{
5460
Namespace: p.Namespace,
5561
Session: sn,
62+
Owner: p.Labels["okdev.io/owner"],
5663
Phase: p.Phase,
5764
Age: age(p.CreatedAt),
5865
Active: sn != "" && sn == activeSession,
@@ -69,15 +76,17 @@ func newListCmd(opts *Options) *cobra.Command {
6976
rows = append(rows, []string{
7077
p.Namespace,
7178
sn,
79+
p.Labels["okdev.io/owner"],
7280
p.Phase,
7381
age(p.CreatedAt),
7482
})
7583
}
76-
output.PrintTable(cmd.OutOrStdout(), []string{"NAMESPACE", "SESSION", "PHASE", "AGE"}, rows)
84+
output.PrintTable(cmd.OutOrStdout(), []string{"NAMESPACE", "SESSION", "OWNER", "PHASE", "AGE"}, rows)
7785
return nil
7886
},
7987
}
8088

8189
cmd.Flags().BoolVar(&allNamespaces, "all-namespaces", false, "List sessions across all namespaces")
90+
cmd.Flags().BoolVar(&allUsers, "all-users", false, "List sessions for all owners")
8291
return cmd
8392
}

internal/cli/ports.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ func newPortsCmd(opts *Options) *cobra.Command {
2424
if err != nil {
2525
return err
2626
}
27+
k := newKubeClient(opts)
28+
if err := ensureSessionOwnership(opts, k, ns, sn, true); err != nil {
29+
return err
30+
}
2731
stopRenew, err := acquireSessionLock(opts, cfg, ns, sn, cmd.OutOrStdout())
2832
if err != nil {
2933
return err
@@ -49,7 +53,6 @@ func newPortsCmd(opts *Options) *cobra.Command {
4953
ctx, cancel := interactiveContext()
5054
defer cancel()
5155
fmt.Fprintf(cmd.OutOrStdout(), "Forwarding %v from session %s\n", forwards, sn)
52-
k := newKubeClient(opts)
5356
slog.Debug("ports start", "namespace", ns, "session", sn, "forwards", forwards)
5457
return portsrunner.ForwardWithRetry(ctx, k, ns, podName(sn), forwards, os.Stdout, cmd.ErrOrStderr(), 30*time.Second)
5558
},

internal/cli/prune.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
func newPruneCmd(opts *Options) *cobra.Command {
1212
var allNamespaces bool
13+
var allUsers bool
1314
var ttlHours int
1415
var includePVC bool
1516
var dryRun bool
@@ -32,7 +33,11 @@ func newPruneCmd(opts *Options) *cobra.Command {
3233
ctx, cancel := defaultContext()
3334
defer cancel()
3435
k := newKubeClient(opts)
35-
pods, err := k.ListPods(ctx, ns, allNamespaces, "okdev.io/managed=true")
36+
label := "okdev.io/managed=true"
37+
if !allUsers {
38+
label = label + "," + ownerLabelSelector(opts)
39+
}
40+
pods, err := k.ListPods(ctx, ns, allNamespaces, label)
3641
if err != nil {
3742
return err
3843
}
@@ -102,6 +107,7 @@ func newPruneCmd(opts *Options) *cobra.Command {
102107
}
103108

104109
cmd.Flags().BoolVar(&allNamespaces, "all-namespaces", false, "Prune sessions across all namespaces")
110+
cmd.Flags().BoolVar(&allUsers, "all-users", false, "Prune sessions for all owners")
105111
cmd.Flags().IntVar(&ttlHours, "ttl-hours", 0, "TTL in hours override")
106112
cmd.Flags().BoolVar(&includePVC, "include-pvc", false, "Delete default workspace PVCs for pruned sessions")
107113
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview prune actions without deleting resources")

0 commit comments

Comments
 (0)