Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
7e1d22f
feat: add workspace-scoped user access
yottahmd Apr 20, 2026
445ad95
feat: separate aggregate and no-workspace scopes
yottahmd Apr 20, 2026
21c6267
fix: harden workspace scope handling
yottahmd Apr 20, 2026
72f8182
fix: harden document workspace lookups
yottahmd Apr 20, 2026
8a2e4bb
fix: avoid sync polling stale repair
yottahmd Apr 20, 2026
a13bb64
fix: constrain workspace match scopes
yottahmd Apr 20, 2026
820d896
Merge branch 'main' into workspace-scoped-access
yottahmd Apr 20, 2026
68bec8f
fix: address workspace review feedback
yottahmd Apr 20, 2026
4dcd6de
test: select no-workspace scope for dag creation e2e
yottahmd Apr 20, 2026
7ae60d7
fix: expose workspace targets on exact endpoints
yottahmd Apr 20, 2026
6fe7395
test: use no-workspace scope for dag crud e2e
yottahmd Apr 20, 2026
e07640d
test: attach proc heartbeat to abort handler runs
yottahmd Apr 20, 2026
ee18d82
fix: repair distributed status from heartbeat
yottahmd Apr 20, 2026
4bf4bdd
fix: refine workspace scope labels
yottahmd Apr 21, 2026
0cfbaa5
fix: refine workspace selector follow-ups
yottahmd Apr 21, 2026
5e3a7b1
fix: address docs url review feedback
yottahmd Apr 21, 2026
84004bc
fix: address remaining review feedback
yottahmd Apr 21, 2026
3bae537
fix: use all for aggregate workspace scope
yottahmd Apr 21, 2026
8d73894
fix: harden workspace scoped access
yottahmd Apr 21, 2026
6abb5ef
fix: address workspace review hardening
yottahmd Apr 21, 2026
4c0e3e4
test: improve workspace helper coverage
yottahmd Apr 21, 2026
a93deb3
fix: use default workspace scope
yottahmd Apr 21, 2026
89361be
fix: address workspace review feedback
yottahmd Apr 21, 2026
8776029
fix: use default workspace in e2e tests
yottahmd Apr 21, 2026
5772ebc
fix: scope document tab mutations by workspace
yottahmd Apr 21, 2026
243c5e1
fix: keep navigation role based
yottahmd Apr 21, 2026
cadd5d4
fix: guard design actions by workspace access
yottahmd Apr 21, 2026
ef75a7c
fix: scope dag run live updates by workspace
yottahmd Apr 21, 2026
f072546
fix: complete dag run workspace guards
yottahmd Apr 21, 2026
7a1509b
fix: scope agent context pickers by workspace
yottahmd Apr 21, 2026
cea1e02
fix: keep resource editors writable in all workspace
yottahmd Apr 21, 2026
2406276
refactor: remove workspace scope from target APIs
yottahmd Apr 21, 2026
a36f44a
docs: clarify workspace API parameters
yottahmd Apr 21, 2026
8b48834
test: stabilize distributed cancellation test
yottahmd Apr 21, 2026
e71141f
fix: preserve workspace scope for DAG search matches
yottahmd Apr 21, 2026
5c95456
refactor: use workspace query parameter consistently
yottahmd Apr 21, 2026
c416907
fix: address workspace review gaps
yottahmd Apr 21, 2026
152b679
fix: simplify workspace storage naming
yottahmd Apr 21, 2026
250d66c
refactor: simplify workspace selection state
yottahmd Apr 21, 2026
ee4e488
test: stabilize local queue FIFO assertion
yottahmd Apr 21, 2026
f80686a
fix: preserve workspace cache state
yottahmd Apr 21, 2026
58cde5d
fix: revalidate workspace target caches
yottahmd Apr 21, 2026
7109f2f
test: cover default workspace scoped role
yottahmd Apr 22, 2026
c2e1909
test: stabilize agent running assertion
yottahmd Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,533 changes: 938 additions & 595 deletions api/v1/api.gen.go

Large diffs are not rendered by default.

137 changes: 131 additions & 6 deletions api/v1/api.yaml

Large diffs are not rendered by default.

85 changes: 46 additions & 39 deletions internal/auth/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ type APIKey struct {
Description string `json:"description,omitempty"`
// Role determines the API key's permissions.
Role Role `json:"role"`
// WorkspaceAccess restricts access to selected workspaces.
// Nil is treated as all-workspaces for backward compatibility.
WorkspaceAccess *WorkspaceAccess `json:"workspace_access,omitempty"`
// KeyHash is the bcrypt hash of the API key secret.
// Excluded from JSON serialization for security.
KeyHash string `json:"-"`
Expand Down Expand Up @@ -51,48 +54,51 @@ func NewAPIKey(name, description string, role Role, keyHash, keyPrefix, createdB
}
now := time.Now().UTC()
return &APIKey{
ID: uuid.New().String(),
Name: name,
Description: description,
Role: role,
KeyHash: keyHash,
KeyPrefix: keyPrefix,
CreatedAt: now,
UpdatedAt: now,
CreatedBy: createdBy,
ID: uuid.New().String(),
Name: name,
Description: description,
Role: role,
WorkspaceAccess: AllWorkspaceAccess(),
KeyHash: keyHash,
KeyPrefix: keyPrefix,
CreatedAt: now,
UpdatedAt: now,
CreatedBy: createdBy,
}, nil
}

// APIKeyForStorage is used for JSON serialization to persistent storage.
// It includes the key hash which is excluded from the regular APIKey JSON.
type APIKeyForStorage struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Role Role `json:"role"`
KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Role Role `json:"role"`
WorkspaceAccess *WorkspaceAccess `json:"workspace_access,omitempty"`
KeyHash string `json:"key_hash"`
KeyPrefix string `json:"key_prefix"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
}

// ToStorage converts an APIKey to APIKeyForStorage for persistence.
// NOTE: When adding new fields to APIKey or APIKeyForStorage, ensure both
// ToStorage and ToAPIKey are updated to maintain field synchronization.
func (k *APIKey) ToStorage() *APIKeyForStorage {
return &APIKeyForStorage{
ID: k.ID,
Name: k.Name,
Description: k.Description,
Role: k.Role,
KeyHash: k.KeyHash,
KeyPrefix: k.KeyPrefix,
CreatedAt: k.CreatedAt,
UpdatedAt: k.UpdatedAt,
CreatedBy: k.CreatedBy,
LastUsedAt: k.LastUsedAt,
ID: k.ID,
Name: k.Name,
Description: k.Description,
Role: k.Role,
WorkspaceAccess: CloneWorkspaceAccess(k.WorkspaceAccess),
KeyHash: k.KeyHash,
KeyPrefix: k.KeyPrefix,
CreatedAt: k.CreatedAt,
UpdatedAt: k.UpdatedAt,
CreatedBy: k.CreatedBy,
LastUsedAt: k.LastUsedAt,
}
}

Expand All @@ -101,15 +107,16 @@ func (k *APIKey) ToStorage() *APIKeyForStorage {
// ToStorage and ToAPIKey are updated to maintain field synchronization.
func (s *APIKeyForStorage) ToAPIKey() *APIKey {
return &APIKey{
ID: s.ID,
Name: s.Name,
Description: s.Description,
Role: s.Role,
KeyHash: s.KeyHash,
KeyPrefix: s.KeyPrefix,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
CreatedBy: s.CreatedBy,
LastUsedAt: s.LastUsedAt,
ID: s.ID,
Name: s.Name,
Description: s.Description,
Role: s.Role,
WorkspaceAccess: CloneWorkspaceAccess(s.WorkspaceAccess),
KeyHash: s.KeyHash,
KeyPrefix: s.KeyPrefix,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
CreatedBy: s.CreatedBy,
LastUsedAt: s.LastUsedAt,
}
}
79 changes: 43 additions & 36 deletions internal/auth/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ type User struct {
PasswordHash string `json:"-"`
// Role determines the user's permissions.
Role Role `json:"role"`
// WorkspaceAccess restricts access to selected workspaces.
// Nil is treated as all-workspaces for backward compatibility.
WorkspaceAccess *WorkspaceAccess `json:"workspace_access,omitempty"`
// CreatedAt is the timestamp when the user was created.
CreatedAt time.Time `json:"created_at"`
// UpdatedAt is the timestamp when the user was last modified.
Expand All @@ -42,59 +45,63 @@ type User struct {
func NewUser(username string, passwordHash string, role Role) *User {
now := time.Now().UTC()
return &User{
ID: uuid.New().String(),
Username: username,
PasswordHash: passwordHash,
Role: role,
CreatedAt: now,
UpdatedAt: now,
ID: uuid.New().String(),
Username: username,
PasswordHash: passwordHash,
Role: role,
WorkspaceAccess: AllWorkspaceAccess(),
CreatedAt: now,
UpdatedAt: now,
}
}

// UserForStorage is used for JSON serialization to persistent storage.
// It includes the password hash which is excluded from the regular User JSON.
type UserForStorage struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
Role Role `json:"role"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
AuthProvider string `json:"auth_provider,omitempty"`
OIDCIssuer string `json:"oidc_issuer,omitempty"`
OIDCSubject string `json:"oidc_subject,omitempty"`
IsDisabled bool `json:"is_disabled,omitempty"`
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"`
Role Role `json:"role"`
WorkspaceAccess *WorkspaceAccess `json:"workspace_access,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
AuthProvider string `json:"auth_provider,omitempty"`
OIDCIssuer string `json:"oidc_issuer,omitempty"`
OIDCSubject string `json:"oidc_subject,omitempty"`
IsDisabled bool `json:"is_disabled,omitempty"`
}

// ToStorage converts a User to UserForStorage for persistence.
func (u *User) ToStorage() *UserForStorage {
return &UserForStorage{
ID: u.ID,
Username: u.Username,
PasswordHash: u.PasswordHash,
Role: u.Role,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
AuthProvider: u.AuthProvider,
OIDCIssuer: u.OIDCIssuer,
OIDCSubject: u.OIDCSubject,
IsDisabled: u.IsDisabled,
ID: u.ID,
Username: u.Username,
PasswordHash: u.PasswordHash,
Role: u.Role,
WorkspaceAccess: CloneWorkspaceAccess(u.WorkspaceAccess),
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
AuthProvider: u.AuthProvider,
OIDCIssuer: u.OIDCIssuer,
OIDCSubject: u.OIDCSubject,
IsDisabled: u.IsDisabled,
}
}

// ToUser converts UserForStorage back to User.
func (s *UserForStorage) ToUser() *User {
return &User{
ID: s.ID,
Username: s.Username,
PasswordHash: s.PasswordHash,
Role: s.Role,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
AuthProvider: s.AuthProvider,
OIDCIssuer: s.OIDCIssuer,
OIDCSubject: s.OIDCSubject,
IsDisabled: s.IsDisabled,
ID: s.ID,
Username: s.Username,
PasswordHash: s.PasswordHash,
Role: s.Role,
WorkspaceAccess: CloneWorkspaceAccess(s.WorkspaceAccess),
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
AuthProvider: s.AuthProvider,
OIDCIssuer: s.OIDCIssuer,
OIDCSubject: s.OIDCSubject,
IsDisabled: s.IsDisabled,
}
}

Expand Down
142 changes: 142 additions & 0 deletions internal/auth/workspace_access.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (C) 2026 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later

package auth

import (
"errors"
"fmt"
"strings"
)

var (
// ErrInvalidWorkspaceAccess is returned when workspace grants are malformed.
ErrInvalidWorkspaceAccess = errors.New("invalid workspace access")
)

// WorkspaceGrant assigns a role for one workspace.
type WorkspaceGrant struct {
Workspace string `json:"workspace"`
Role Role `json:"role"`
}

// WorkspaceAccess controls which workspaces a user or API key can access.
//
// Missing workspace access is treated as All=true by NormalizeWorkspaceAccess
// for backward compatibility with existing users and API keys.
type WorkspaceAccess struct {
All bool `json:"all"`
Grants []WorkspaceGrant `json:"grants,omitempty"`
}

// AllWorkspaceAccess returns an all-workspaces access policy.
func AllWorkspaceAccess() *WorkspaceAccess {
return &WorkspaceAccess{All: true}
}

// NormalizeWorkspaceAccess returns a stable, non-nil workspace access value.
func NormalizeWorkspaceAccess(access *WorkspaceAccess) WorkspaceAccess {
if access == nil {
return WorkspaceAccess{All: true}
}
if access.All {
return WorkspaceAccess{All: true}
}

grants := make([]WorkspaceGrant, 0, len(access.Grants))
for _, grant := range access.Grants {
grants = append(grants, WorkspaceGrant{
Workspace: strings.TrimSpace(grant.Workspace),
Role: grant.Role,
})
}
return WorkspaceAccess{
All: false,
Grants: grants,
}
}

// CloneWorkspaceAccess returns a normalized copy suitable for storage.
func CloneWorkspaceAccess(access *WorkspaceAccess) *WorkspaceAccess {
normalized := NormalizeWorkspaceAccess(access)
grants := make([]WorkspaceGrant, len(normalized.Grants))
copy(grants, normalized.Grants)
return &WorkspaceAccess{
All: normalized.All,
Grants: grants,
}
}

// EffectiveRole returns the role that applies to a workspace.
//
// Empty workspace names represent unlabelled resources and are governed by the
// global role so non-workspace workflows remain visible to all authenticated users.
func EffectiveRole(globalRole Role, access *WorkspaceAccess, workspaceName string) (Role, bool) {
workspaceName = strings.TrimSpace(workspaceName)
if workspaceName == "" {
return globalRole, true
}

normalized := NormalizeWorkspaceAccess(access)
if normalized.All {
return globalRole, true
}
for _, grant := range normalized.Grants {
if grant.Workspace == workspaceName {
return grant.Role, true
}
}
return RoleNone, false
}

// HasWorkspaceAccess reports whether a workspace is visible to the policy.
func HasWorkspaceAccess(access *WorkspaceAccess, workspaceName string) bool {
_, ok := EffectiveRole(RoleViewer, access, workspaceName)
return ok
}

// ValidateWorkspaceAccess validates role invariants and workspace names.
func ValidateWorkspaceAccess(globalRole Role, access *WorkspaceAccess, workspaceExists func(string) bool) error {
if !globalRole.Valid() {
return fmt.Errorf("%w: invalid global role %q", ErrInvalidWorkspaceAccess, globalRole)
}
if access != nil && access.All && len(access.Grants) != 0 {
return fmt.Errorf("%w: all-workspaces access cannot include workspace grants", ErrInvalidWorkspaceAccess)
}

normalized := NormalizeWorkspaceAccess(access)
if normalized.All {
return nil
}

if globalRole != RoleViewer {
return fmt.Errorf("%w: scoped workspace access requires global role viewer", ErrInvalidWorkspaceAccess)
}
if len(normalized.Grants) == 0 {
return fmt.Errorf("%w: scoped workspace access requires at least one workspace grant", ErrInvalidWorkspaceAccess)
}

seen := make(map[string]struct{}, len(normalized.Grants))
for _, grant := range normalized.Grants {
workspaceName := strings.TrimSpace(grant.Workspace)
if workspaceName == "" {
return fmt.Errorf("%w: workspace name is required", ErrInvalidWorkspaceAccess)
}
if _, ok := seen[workspaceName]; ok {
return fmt.Errorf("%w: duplicate workspace grant %q", ErrInvalidWorkspaceAccess, workspaceName)
}
seen[workspaceName] = struct{}{}

if !grant.Role.Valid() {
return fmt.Errorf("%w: invalid grant role %q", ErrInvalidWorkspaceAccess, grant.Role)
}
if grant.Role == RoleAdmin {
return fmt.Errorf("%w: admin cannot be scoped to a workspace", ErrInvalidWorkspaceAccess)
}
if workspaceExists != nil && !workspaceExists(workspaceName) {
return fmt.Errorf("%w: workspace %q does not exist", ErrInvalidWorkspaceAccess, workspaceName)
}
}

return nil
}
Loading
Loading