diff --git a/.design/system-project.md b/.design/system-project.md new file mode 100644 index 000000000..7618d4444 --- /dev/null +++ b/.design/system-project.md @@ -0,0 +1,171 @@ +# Design: System Project + +## Status + +Approved and implemented on branch `scion/system-project`. + +## Overview + +The system project is a built-in, optional Scion project that gives Hub admins a +dedicated helper-assistant workspace for configuring and managing Scion. It is +separate from the existing global project: global remains a default workspace, +while the system project is a management surface where agents can act with +elevated Hub permissions inherited from their creating admin user. + +The system project is represented as a normal `Project` record with reserved +identity and labels: + +| Field | Value | +| --- | --- | +| Slug | `system` | +| Name | `System` | +| Visibility | `private` | +| Labels | `scion.io/system=true`, `scion.io/system-project=true` | + +## Goals + +1. Create an optional built-in `system` project at Hub startup. +2. Give system-project agents assistant-mode CLI access instead of restricted + agent-mode CLI access. +3. Allow system-project agents created by Hub admins to evaluate and enforce + Hub authorization the same way at runtime and via policy evaluation. +4. Keep the feature compatible with co-located and HA deployments by supporting + a configurable workspace path. + +## Non-Goals + +1. Removing or replacing the global project. +2. Adding a dedicated system-project UI. +3. Creating a shared, global system agent. +4. Granting elevated permissions to non-admin-created agents. + +## Enablement + +The feature is disabled by default and controlled by Hub server config: + +```yaml +server: + system_project: + enabled: true + workspace_path: /optional/shared/path +``` + +Equivalent CLI flags are available: + +```bash +scion server start --enable-system-project +scion server start --enable-system-project --system-project-workspace-path /mnt/nfs/scion/system-project +``` + +When disabled, startup is a no-op and existing system-project records are not +deleted. + +## Startup Registration + +When enabled, the foreground server startup path calls `registerSystemProject` +after the co-located broker identity is known. Registration is idempotent: + +1. Resolve the workspace path. +2. Create the workspace directory tree. +3. Look up project slug `system`. +4. Create the project if missing. +5. Backfill missing system labels, default broker ID, shared dir, and provider + mapping if the project already exists. +6. Ensure the project members group and policy bindings exist. + +The system project reuses the existing co-located runtime broker rather than +creating a dedicated broker. + +## Workspace Layout + +By default, the workspace lives under the Scion global directory: + +```text +~/.scion/system-project/ +├── shared/ +│ ├── journal.md +│ ├── notes/ +│ └── runbooks/ +├── agents/ +└── config/ +``` + +`server.system_project.workspace_path` overrides this for shared-storage or HA +deployments, for example an NFS mount. + +## Reserved Slugs + +The slugs `global` and `system` are reserved. User-driven project creation and +project registration reject reserved slugs unless the caller is a Hub admin. +This prevents a regular user from pre-empting the `system` slug before startup +registration and then gaining elevated semantics from the reserved identity. + +## Access Control + +The system project uses standard project membership: + +1. Hub admins have access through the existing admin bypass. +2. Named users can be added to `project:system:members`. +3. The project remains private and should not appear to unauthorized users. + +For elevated Hub API access, system-project agents use ancestry-based admin +delegation. If an agent belongs to the system project and its origin user +(`Ancestry[0]`) is currently a Hub admin, authorization grants admin-equivalent +access with reason `system project admin delegation`. + +The admin role is checked at evaluation time, so removing the origin user's admin +role revokes delegated access. + +The policy evaluation endpoint must populate the same agent ancestry used by +runtime enforcement. Otherwise `/api/v1/policies/evaluate` can disagree with +actual access checks for system-project agents. + +## CLI Mode + +Normal agents receive `SCION_CLI_MODE=agent`. System-project agents receive +`SCION_CLI_MODE=assistant`. + +Assistant mode exposes broader management commands while still blocking +security-sensitive or interactive commands such as Hub auth flows, token +management, config migration, direct directory-changing helpers, reconnect, and +cleanup. + +The Hub dispatcher applies this mode when creating, starting, and restarting +agents so a restarted system-project agent keeps assistant mode. + +## Capability Reporting + +Capability precomputation follows the same system-project delegation rule as +direct authorization checks. UI and API capability responses therefore report +the same access that runtime enforcement will allow. + +## Implementation Map + +| Concern | Location | +| --- | --- | +| Server flags and constants | `cmd/server.go` | +| Startup registration | `cmd/server_foreground.go`, `cmd/server_broker.go` | +| Daemon flag forwarding | `cmd/server_daemon.go` | +| Config model and V1 settings | `pkg/config/hub_config.go`, `pkg/config/settings_v1.go` | +| Workspace path helper | `pkg/config/paths.go` | +| System labels and reserved slugs | `pkg/projectcompat/labels.go` | +| CLI mode dispatch | `pkg/hub/httpdispatcher.go` | +| Authorization delegation | `pkg/hub/authz.go` | +| Capability precompute | `pkg/hub/capabilities.go` | +| Policy evaluate ancestry | `pkg/hub/handlers_policies.go` | +| Reserved slug enforcement | `pkg/hub/handlers.go` | + +## Verification + +Focused coverage includes: + +1. Disabled registration is a no-op. +2. Enabled registration creates and backfills the `system` project, provider, + workspace tree, shared dir, labels, and members group. +3. System-project create/start/restart dispatch sets `SCION_CLI_MODE=assistant`. +4. Normal project dispatch preserves/defaults agent CLI mode correctly. +5. System-project agents with admin ancestry receive delegated access. +6. Delegation is revoked when the origin user is no longer an admin. +7. `/api/v1/policies/evaluate` uses stored agent ancestry. +8. Non-admin users cannot create or register reserved slugs. + diff --git a/cmd/server.go b/cmd/server.go index f3f5e90c8..56288388c 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -18,23 +18,29 @@ import ( "github.com/spf13/cobra" ) -// GlobalProjectName is the special name for the default project when hub and runtime-broker run together -const GlobalProjectName = "global" +const ( + // GlobalProjectName is the special name for the default project when hub and runtime-broker run together. + GlobalProjectName = "global" + // SystemProjectName is the reserved slug for the built-in administration project. + SystemProjectName = "system" +) var ( - serverConfigPath string - hubPort int - hubHost string - enableHub bool - enableRuntimeBroker bool - runtimeBrokerPort int - dbURL string - noAutoMigrate bool - enableDevAuth bool - enableTestLogin bool - enableDebug bool - storageBucket string - storageDir string + serverConfigPath string + hubPort int + hubHost string + enableHub bool + enableRuntimeBroker bool + runtimeBrokerPort int + dbURL string + noAutoMigrate bool + enableDevAuth bool + enableTestLogin bool + enableDebug bool + storageBucket string + storageDir string + enableSystemProject bool + systemProjectWorkspacePath string // Template cache settings for Runtime Broker templateCacheDir string @@ -257,6 +263,8 @@ func init() { // Storage flags serverStartCmd.Flags().StringVar(&storageBucket, "storage-bucket", "", "GCS bucket name for template storage") serverStartCmd.Flags().StringVar(&storageDir, "storage-dir", "", "Local directory for template storage (alternative to GCS)") + serverStartCmd.Flags().BoolVar(&enableSystemProject, "enable-system-project", false, "Enable the built-in system project") + serverStartCmd.Flags().StringVar(&systemProjectWorkspacePath, "system-project-workspace-path", "", "Workspace path for the system project") // Template cache flags (for Runtime Broker) serverStartCmd.Flags().StringVar(&templateCacheDir, "template-cache-dir", "", "Directory for caching templates from Hub (default: ~/.scion/cache/templates)") diff --git a/cmd/server_broker.go b/cmd/server_broker.go index e3f1c2d3a..042a4fc04 100644 --- a/cmd/server_broker.go +++ b/cmd/server_broker.go @@ -16,12 +16,16 @@ package cmd import ( "context" + "errors" "fmt" "log" + "os" + "path/filepath" "time" "github.com/GoogleCloudPlatform/scion/pkg/api" "github.com/GoogleCloudPlatform/scion/pkg/config" + "github.com/GoogleCloudPlatform/scion/pkg/projectcompat" "github.com/GoogleCloudPlatform/scion/pkg/runtime" "github.com/GoogleCloudPlatform/scion/pkg/store" ) @@ -173,6 +177,224 @@ func registerGlobalProjectAndBroker(ctx context.Context, s store.Store, brokerID return brokerID, nil } +func registerSystemProject(ctx context.Context, s store.Store, brokerID, brokerName string, cfg config.SystemProjectConfig) error { + if !cfg.Enabled { + return nil + } + + workspacePath, err := config.GetSystemProjectDir(cfg.WorkspacePath) + if err != nil { + return fmt.Errorf("failed to resolve system project path: %w", err) + } + if err := provisionSystemProjectWorkspace(workspacePath); err != nil { + return err + } + + project, err := s.GetProjectBySlug(ctx, SystemProjectName) + if err != nil && !errors.Is(err, store.ErrNotFound) { + return fmt.Errorf("failed to check for system project: %w", err) + } + + if project == nil { + project = &store.Project{ + ID: api.NewUUID(), + Name: "System", + Slug: SystemProjectName, + Visibility: store.VisibilityPrivate, + Labels: map[string]string{ + projectcompat.LabelScionSystem: "true", + projectcompat.LabelSystemProject: "true", + }, + DefaultRuntimeBrokerID: brokerID, + SharedDirs: []api.SharedDir{ + {Name: "shared", ReadOnly: false, InWorkspace: false}, + }, + } + if err := s.CreateProject(ctx, project); err != nil { + return fmt.Errorf("failed to create system project: %w", err) + } + } else if backfillSystemProject(project, brokerID) { + if err := s.UpdateProject(ctx, project); err != nil { + return fmt.Errorf("failed to update system project: %w", err) + } + } + + provider := &store.ProjectProvider{ + ProjectID: project.ID, + BrokerID: brokerID, + BrokerName: brokerName, + LocalPath: workspacePath, + Status: store.BrokerStatusOnline, + LastSeen: time.Now(), + } + if err := s.AddProjectProvider(ctx, provider); err != nil { + if !errors.Is(err, store.ErrAlreadyExists) { + return fmt.Errorf("failed to add system project provider: %w", err) + } + if existing, getErr := s.GetProjectProvider(ctx, project.ID, brokerID); getErr == nil && existing.LocalPath != workspacePath { + log.Printf("Error: system project workspace path differs: stored=%q current=%q; restart with correct path or update provider manually", existing.LocalPath, workspacePath) + } + if err := s.UpdateProviderStatus(ctx, project.ID, brokerID, store.BrokerStatusOnline); err != nil { + log.Printf("Warning: failed to update system project provider status: %v", err) + } + } + + ensureProjectMembersGroupAndPolicy(ctx, s, project) + return nil +} + +func provisionSystemProjectWorkspace(workspacePath string) error { + for _, dir := range []string{ + filepath.Join(workspacePath, "shared"), + filepath.Join(workspacePath, "shared", "notes"), + filepath.Join(workspacePath, "shared", "runbooks"), + filepath.Join(workspacePath, "agents"), + filepath.Join(workspacePath, "config"), + } { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create system project directory %s: %w", dir, err) + } + } + + journalPath := filepath.Join(workspacePath, "shared", "journal.md") + if _, err := os.Stat(journalPath); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to stat system project journal: %w", err) + } + if err := os.WriteFile(journalPath, []byte("# System Project Journal\n\n"), 0644); err != nil { + return fmt.Errorf("failed to seed system project journal: %w", err) + } + return nil +} + +func backfillSystemProject(project *store.Project, brokerID string) bool { + changed := false + if project.Labels == nil { + project.Labels = map[string]string{} + } + if project.Labels[projectcompat.LabelScionSystem] != "true" { + project.Labels[projectcompat.LabelScionSystem] = "true" + changed = true + } + if project.Labels[projectcompat.LabelSystemProject] != "true" { + project.Labels[projectcompat.LabelSystemProject] = "true" + changed = true + } + if project.DefaultRuntimeBrokerID == "" { + project.DefaultRuntimeBrokerID = brokerID + changed = true + } + if !hasSharedDir(project.SharedDirs, "shared") { + project.SharedDirs = append(project.SharedDirs, api.SharedDir{Name: "shared", ReadOnly: false, InWorkspace: false}) + changed = true + } + return changed +} + +func hasSharedDir(sharedDirs []api.SharedDir, name string) bool { + for _, dir := range sharedDirs { + if dir.Name == name { + return true + } + } + return false +} + +func ensureProjectMembersGroupAndPolicy(ctx context.Context, s store.Store, project *store.Project) { + membersSlug := "project:" + project.Slug + ":members" + membersGroup := &store.Group{ + ID: api.NewUUID(), + Name: project.Name + " Members", + Slug: membersSlug, + GroupType: store.GroupTypeExplicit, + ProjectID: project.ID, + OwnerID: project.OwnerID, + CreatedBy: project.CreatedBy, + } + if err := s.CreateGroup(ctx, membersGroup); err != nil { + if !errors.Is(err, store.ErrAlreadyExists) { + log.Printf("Warning: failed to create project members group for %s: %v", project.Slug, err) + return + } + existing, lookupErr := s.GetGroupBySlug(ctx, membersSlug) + if lookupErr != nil { + log.Printf("Warning: failed to look up project members group for %s: %v", project.Slug, lookupErr) + return + } + membersGroup = existing + needsUpdate := false + if membersGroup.ProjectID != project.ID { + membersGroup.ProjectID = project.ID + needsUpdate = true + } + if membersGroup.OwnerID == "" && project.OwnerID != "" { + membersGroup.OwnerID = project.OwnerID + needsUpdate = true + } + if needsUpdate { + if updateErr := s.UpdateGroup(ctx, membersGroup); updateErr != nil { + log.Printf("Warning: failed to update project members group for %s: %v", project.Slug, updateErr) + } + } + } + + policyName := "project:" + project.Slug + ":member-create-agents" + policy := &store.Policy{ + ID: api.NewUUID(), + Name: policyName, + Description: "Allow project members to create and stop agents", + ScopeType: store.PolicyScopeProject, + ScopeID: project.ID, + ResourceType: "agent", + Actions: []string{"create", "stop_all"}, + Effect: store.PolicyEffectAllow, + } + if err := s.CreatePolicy(ctx, policy); err != nil { + if !errors.Is(err, store.ErrAlreadyExists) { + log.Printf("Warning: failed to create project members policy for %s: %v", project.Slug, err) + return + } + existing, lookupErr := s.ListPolicies(ctx, store.PolicyFilter{Name: policyName}, store.ListOptions{Limit: 1}) + if lookupErr != nil || len(existing.Items) == 0 { + log.Printf("Warning: failed to look up project members policy for %s: %v", project.Slug, lookupErr) + return + } + policy = &existing.Items[0] + needsUpdate := false + if policy.ScopeID != project.ID { + policy.ScopeID = project.ID + needsUpdate = true + } + if !stringSliceContains(policy.Actions, "stop_all") { + policy.Actions = append(policy.Actions, "stop_all") + needsUpdate = true + } + if needsUpdate { + if updateErr := s.UpdatePolicy(ctx, policy); updateErr != nil { + log.Printf("Warning: failed to update project members policy for %s: %v", project.Slug, updateErr) + } + } + } + + if err := s.AddPolicyBinding(ctx, &store.PolicyBinding{ + PolicyID: policy.ID, + PrincipalType: store.PolicyPrincipalTypeGroup, + PrincipalID: membersGroup.ID, + }); err != nil && !errors.Is(err, store.ErrAlreadyExists) { + log.Printf("Warning: failed to bind project members policy for %s: %v", project.Slug, err) + } +} + +func stringSliceContains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + // buildStoreBrokerProfiles builds store.BrokerProfile objects from settings.Profiles. // If no profiles are defined in settings, returns a default profile with the detected runtime type. func buildStoreBrokerProfiles(settings *config.Settings, defaultRuntimeType string) []store.BrokerProfile { diff --git a/cmd/server_daemon.go b/cmd/server_daemon.go index e6e061ef4..47fb0f3d9 100644 --- a/cmd/server_daemon.go +++ b/cmd/server_daemon.go @@ -112,6 +112,12 @@ func buildDaemonStartArgs(cmd *cobra.Command) []string { if cmd.Flags().Changed("storage-dir") { daemonArgs = append(daemonArgs, fmt.Sprintf("--storage-dir=%s", storageDir)) } + if cmd.Flags().Changed("enable-system-project") { + daemonArgs = append(daemonArgs, "--enable-system-project") + } + if cmd.Flags().Changed("system-project-workspace-path") { + daemonArgs = append(daemonArgs, fmt.Sprintf("--system-project-workspace-path=%s", systemProjectWorkspacePath)) + } // String/int flags registered only on serverStartCmd: forward when explicitly // set so they survive the re-exec into the --foreground child rather than // falling back to defaults (e.g. --session-secret would otherwise be diff --git a/cmd/server_foreground.go b/cmd/server_foreground.go index 44110c521..781ab351a 100644 --- a/cmd/server_foreground.go +++ b/cmd/server_foreground.go @@ -694,6 +694,12 @@ func loadAndReconcileConfig(cmd *cobra.Command) (*config.GlobalConfig, error) { if cmd.Flags().Changed("storage-dir") { cfg.Storage.LocalPath = storageDir } + if cmd.Flags().Changed("enable-system-project") { + cfg.SystemProject.Enabled = enableSystemProject + } + if cmd.Flags().Changed("system-project-workspace-path") { + cfg.SystemProject.WorkspacePath = systemProjectWorkspacePath + } // Standalone broker in hosted mode: default to loopback when host // is not explicitly set. The broker needs to start on loopback so that @@ -1863,6 +1869,11 @@ func startRuntimeBroker(ctx context.Context, cmd *cobra.Command, cfg *config.Glo hubSrv.SetEmbeddedBrokerID(brokerID) } hubSrv.SetLocalImageChecker(rt) + if cfg.SystemProject.Enabled { + if err := registerSystemProject(ctx, s, brokerID, brokerName, cfg.SystemProject); err != nil { + log.Printf("Warning: failed to register system project: %v", err) + } + } } // Generate or retrieve credentials for co-located mode (idempotent). diff --git a/cmd/server_test.go b/cmd/server_test.go index ffca2ed6d..32862e6e2 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -18,11 +18,14 @@ package cmd import ( "context" + "os" + "path/filepath" "strings" "testing" "github.com/GoogleCloudPlatform/scion/pkg/config" "github.com/GoogleCloudPlatform/scion/pkg/ent/entc" + "github.com/GoogleCloudPlatform/scion/pkg/projectcompat" "github.com/GoogleCloudPlatform/scion/pkg/store" "github.com/GoogleCloudPlatform/scion/pkg/store/entadapter" "github.com/stretchr/testify/assert" @@ -75,6 +78,109 @@ func TestRegisterGlobalGroveAndBroker_DedupByName(t *testing.T) { assert.Equal(t, store.BrokerStatusOnline, broker.Status) } +func TestRegisterSystemProject_DisabledNoop(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + workspace := filepath.Join(t.TempDir(), "system-project") + + require.NoError(t, registerSystemProject(ctx, s, tid("broker-1"), "test-broker", config.SystemProjectConfig{ + Enabled: false, + WorkspacePath: workspace, + })) + + _, err := s.GetProjectBySlug(ctx, SystemProjectName) + assert.ErrorIs(t, err, store.ErrNotFound) + _, err = os.Stat(workspace) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestRegisterSystemProject_CreatesProjectWorkspaceAndPolicy(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + workspace := filepath.Join(t.TempDir(), "system-project") + + require.NoError(t, registerSystemProject(ctx, s, tid("broker-1"), "test-broker", config.SystemProjectConfig{ + Enabled: true, + WorkspacePath: workspace, + })) + + project, err := s.GetProjectBySlug(ctx, SystemProjectName) + require.NoError(t, err) + assert.Equal(t, "System", project.Name) + assert.Equal(t, store.VisibilityPrivate, project.Visibility) + assert.Equal(t, "true", project.Labels[projectcompat.LabelScionSystem]) + assert.Equal(t, "true", project.Labels[projectcompat.LabelSystemProject]) + assert.Equal(t, tid("broker-1"), project.DefaultRuntimeBrokerID) + require.Len(t, project.SharedDirs, 1) + assert.Equal(t, "shared", project.SharedDirs[0].Name) + + for _, rel := range []string{"shared", "shared/notes", "shared/runbooks", "agents", "config"} { + info, err := os.Stat(filepath.Join(workspace, rel)) + require.NoError(t, err) + assert.True(t, info.IsDir(), rel) + } + journal, err := os.ReadFile(filepath.Join(workspace, "shared", "journal.md")) + require.NoError(t, err) + assert.Contains(t, string(journal), "System Project Journal") + + provider, err := s.GetProjectProvider(ctx, project.ID, tid("broker-1")) + require.NoError(t, err) + assert.Equal(t, workspace, provider.LocalPath) + assert.Equal(t, store.BrokerStatusOnline, provider.Status) + + group, err := s.GetGroupBySlug(ctx, "project:"+SystemProjectName+":members") + require.NoError(t, err) + assert.Equal(t, project.ID, group.ProjectID) + + policies, err := s.ListPolicies(ctx, store.PolicyFilter{Name: "project:" + SystemProjectName + ":member-create-agents"}, store.ListOptions{Limit: 1}) + require.NoError(t, err) + require.Len(t, policies.Items, 1) + assert.Equal(t, project.ID, policies.Items[0].ScopeID) + assert.ElementsMatch(t, []string{"create", "stop_all"}, policies.Items[0].Actions) + bindings, err := s.GetPolicyBindings(ctx, policies.Items[0].ID) + require.NoError(t, err) + require.Len(t, bindings, 1) + assert.Equal(t, group.ID, bindings[0].PrincipalID) +} + +func TestRegisterSystemProject_IdempotentPreservesJournal(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + workspace := filepath.Join(t.TempDir(), "system-project") + + require.NoError(t, registerSystemProject(ctx, s, tid("broker-1"), "test-broker", config.SystemProjectConfig{ + Enabled: true, + WorkspacePath: workspace, + })) + journalPath := filepath.Join(workspace, "shared", "journal.md") + require.NoError(t, os.WriteFile(journalPath, []byte("keep me\n"), 0644)) + + require.NoError(t, registerSystemProject(ctx, s, tid("broker-1"), "test-broker", config.SystemProjectConfig{ + Enabled: true, + WorkspacePath: workspace, + })) + + journal, err := os.ReadFile(journalPath) + require.NoError(t, err) + assert.Equal(t, "keep me\n", string(journal)) + + result, err := s.ListProjects(ctx, store.ProjectFilter{}, store.ListOptions{}) + require.NoError(t, err) + count := 0 + for _, project := range result.Items { + if project.Slug == SystemProjectName { + count++ + } + } + assert.Equal(t, 1, count) + + project, err := s.GetProjectBySlug(ctx, SystemProjectName) + require.NoError(t, err) + groups, err := s.ListGroups(ctx, store.GroupFilter{ProjectID: project.ID}, store.ListOptions{}) + require.NoError(t, err) + assert.Len(t, groups.Items, 1) +} + func TestRegisterGlobalGroveAndBroker_SameIDNoDedup(t *testing.T) { ctx := context.Background() s := newTestStore(t) diff --git a/pkg/agent/run.go b/pkg/agent/run.go index cf487db25..58f453671 100644 --- a/pkg/agent/run.go +++ b/pkg/agent/run.go @@ -610,7 +610,9 @@ authDone: opts.Env["SCION_CREATOR"] = u.Username } } - opts.Env["SCION_CLI_MODE"] = "agent" + if _, ok := opts.Env["SCION_CLI_MODE"]; !ok { + opts.Env["SCION_CLI_MODE"] = "agent" + } // Determine whether hub is explicitly disabled in project settings. // When disabled, we suppress hub env var injection from agent config diff --git a/pkg/config/hub_config.go b/pkg/config/hub_config.go index 0810fc86a..932d2b3aa 100644 --- a/pkg/config/hub_config.go +++ b/pkg/config/hub_config.go @@ -164,6 +164,12 @@ type RuntimeBrokerConfig struct { AllowContainerScriptHarnesses bool `json:"allowContainerScriptHarnesses" yaml:"allowContainerScriptHarnesses" koanf:"allowContainerScriptHarnesses"` } +// SystemProjectConfig holds settings for the built-in system project. +type SystemProjectConfig struct { + Enabled bool `json:"enabled" yaml:"enabled" koanf:"enabled"` + WorkspacePath string `json:"workspacePath,omitempty" yaml:"workspacePath,omitempty" koanf:"workspacePath"` +} + // DatabaseConfig holds database connection settings. type DatabaseConfig struct { Driver string `json:"driver" yaml:"driver" koanf:"driver"` // sqlite, postgres @@ -322,6 +328,9 @@ type GlobalConfig struct { // Runtime Broker API server settings RuntimeBroker RuntimeBrokerConfig `json:"runtimeBroker" yaml:"runtimeBroker" koanf:"runtimeBroker"` + // SystemProject controls the optional built-in administration project. + SystemProject SystemProjectConfig `json:"systemProject" yaml:"systemProject" koanf:"systemProject"` + // Database settings Database DatabaseConfig `json:"database" yaml:"database" koanf:"database"` @@ -413,6 +422,9 @@ func DefaultGlobalConfig() GlobalConfig { CORSMaxAge: 3600, AllowContainerScriptHarnesses: true, }, + SystemProject: SystemProjectConfig{ + Enabled: false, + }, Database: DatabaseConfig{ Driver: "sqlite", URL: "", // Will be set to default path if empty @@ -600,6 +612,8 @@ func loadGlobalConfigLegacy(configPath string) (*GlobalConfig, error) { "runtimeBroker.corsAllowedHeaders": defaults.RuntimeBroker.CORSAllowedHeaders, "runtimeBroker.corsMaxAge": defaults.RuntimeBroker.CORSMaxAge, "runtimeBroker.allowContainerScriptHarnesses": defaults.RuntimeBroker.AllowContainerScriptHarnesses, + "systemProject.enabled": defaults.SystemProject.Enabled, + "systemProject.workspacePath": defaults.SystemProject.WorkspacePath, // Database defaults "database.driver": defaults.Database.Driver, "database.url": defaults.Database.URL, @@ -759,10 +773,18 @@ func envKeyToConfigKey(envKey string) string { "adminmode": "adminMode", "maintenancemessage": "maintenanceMessage", "disablelegacystoragefallback": "disableLegacyStorageFallback", + "systemproject": "systemProject", + "workspacepath": "workspacePath", } // Split by underscore, convert each part parts := strings.Split(strings.ToLower(envKey), "_") + if len(parts) >= 2 && parts[0] == "system" && parts[1] == "project" { + parts = append([]string{"systemProject"}, parts[2:]...) + if len(parts) >= 3 && parts[1] == "workspace" && parts[2] == "path" { + parts = append([]string{"systemProject", "workspacePath"}, parts[3:]...) + } + } for i, part := range parts { if replacement, ok := camelCaseFields[part]; ok { parts[i] = replacement diff --git a/pkg/config/hub_config_test.go b/pkg/config/hub_config_test.go index 641e1cd90..aa98d915c 100644 --- a/pkg/config/hub_config_test.go +++ b/pkg/config/hub_config_test.go @@ -316,6 +316,9 @@ func TestEnvKeyToConfigKey(t *testing.T) { {"SECRETS_BACKEND", "secrets.backend"}, {"SECRETS_GCPPROJECTID", "secrets.gcpProjectId"}, {"SECRETS_GCPCREDENTIALS", "secrets.gcpCredentials"}, + {"SYSTEM_PROJECT_ENABLED", "systemProject.enabled"}, + {"SYSTEM_PROJECT_WORKSPACEPATH", "systemProject.workspacePath"}, + {"SYSTEM_PROJECT_WORKSPACE_PATH", "systemProject.workspacePath"}, } for _, tc := range tests { diff --git a/pkg/config/paths.go b/pkg/config/paths.go index 4ef84e707..4be78a457 100644 --- a/pkg/config/paths.go +++ b/pkg/config/paths.go @@ -161,6 +161,17 @@ func GetGlobalDir() (string, error) { return filepath.Join(home, GlobalDir), nil } +func GetSystemProjectDir(override string) (string, error) { + if override != "" { + return filepath.Abs(override) + } + globalDir, err := GetGlobalDir() + if err != nil { + return "", err + } + return filepath.Join(globalDir, "system-project"), nil +} + // GetProjectConfigDir returns the directory where project config files (settings.yaml, // templates/) live. For git projects with split storage (project-id file exists), this // is the external path under ~/.scion/project-configs/. For all other projects diff --git a/pkg/config/schemas/settings-v1.schema.json b/pkg/config/schemas/settings-v1.schema.json index 844632a32..3c4d08c2a 100644 --- a/pkg/config/schemas/settings-v1.schema.json +++ b/pkg/config/schemas/settings-v1.schema.json @@ -58,6 +58,7 @@ }, "hub": { "$ref": "#/$defs/serverHub" }, "broker": { "$ref": "#/$defs/serverBroker" }, + "system_project": { "$ref": "#/$defs/serverSystemProject" }, "database": { "$ref": "#/$defs/serverDatabase" }, "auth": { "$ref": "#/$defs/serverAuth" }, "oauth": { "$ref": "#/$defs/serverOAuth" }, @@ -819,6 +820,26 @@ "cors": { "$ref": "#/$defs/corsConfig" } } }, + "serverSystemProject": { + "type": "object", + "description": "Built-in system project settings.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable the built-in system project.", + "x-env-var": "SCION_SERVER_SYSTEM_PROJECT_ENABLED", + "x-since": "1" + }, + "workspace_path": { + "type": "string", + "description": "Filesystem workspace path for the system project. Defaults to ~/.scion/system-project.", + "x-env-var": "SCION_SERVER_SYSTEM_PROJECT_WORKSPACE_PATH", + "x-since": "1" + } + }, + "additionalProperties": false + }, "corsConfig": { "type": "object", "properties": { diff --git a/pkg/config/settings_v1.go b/pkg/config/settings_v1.go index 3b91bf29d..772e00c05 100644 --- a/pkg/config/settings_v1.go +++ b/pkg/config/settings_v1.go @@ -264,6 +264,7 @@ type V1ServerConfig struct { Env string `json:"env,omitempty" yaml:"env,omitempty" koanf:"env"` Hub *V1ServerHubConfig `json:"hub,omitempty" yaml:"hub,omitempty" koanf:"hub"` Broker *V1BrokerConfig `json:"broker,omitempty" yaml:"broker,omitempty" koanf:"broker"` + SystemProject *V1SystemProjectConfig `json:"system_project,omitempty" yaml:"system_project,omitempty" koanf:"system_project"` Database *V1DatabaseConfig `json:"database,omitempty" yaml:"database,omitempty" koanf:"database"` Auth *V1AuthConfig `json:"auth,omitempty" yaml:"auth,omitempty" koanf:"auth"` OAuth *V1OAuthConfig `json:"oauth,omitempty" yaml:"oauth,omitempty" koanf:"oauth"` @@ -290,6 +291,12 @@ type V1ServerConfig struct { GitHubApp *V1GitHubAppConfig `json:"github_app,omitempty" yaml:"github_app,omitempty" koanf:"github_app"` } +// V1SystemProjectConfig holds the built-in system project settings in settings.yaml format. +type V1SystemProjectConfig struct { + Enabled bool `json:"enabled" yaml:"enabled" koanf:"enabled"` + WorkspacePath string `json:"workspace_path,omitempty" yaml:"workspace_path,omitempty" koanf:"workspace_path"` +} + // V1GitHubAppConfig holds the GitHub App configuration in settings.yaml format. type V1GitHubAppConfig struct { AppID int64 `json:"app_id,omitempty" yaml:"app_id,omitempty" koanf:"app_id"` @@ -983,8 +990,10 @@ var knownCompoundFields = []string{ "require_trusted_proxy_ip", "soft_delete_retain_files", "soft_delete_retention", + "system_project", "authorized_domains", "platform_auth_sa", + "workspace_path", "oidc_audience", "jwks_url", "broker_nickname", @@ -1078,7 +1087,7 @@ func mapEnvKeyRecursive(key string) string { // isSectionName checks if a name is a known section in the server config hierarchy. func isSectionName(name string) bool { switch name { - case "hub", "broker", "database", "auth", "oauth", "storage", "secrets", "cors", + case "hub", "broker", "system_project", "database", "auth", "oauth", "storage", "secrets", "cors", "web", "cli", "device", "google", "github", "proxy", "iap", "transport": return true } @@ -1314,6 +1323,13 @@ func ConvertV1ServerToGlobalConfig(v1 *V1ServerConfig) *GlobalConfig { } } + if v1.SystemProject != nil { + gc.SystemProject.Enabled = v1.SystemProject.Enabled + if v1.SystemProject.WorkspacePath != "" { + gc.SystemProject.WorkspacePath = v1.SystemProject.WorkspacePath + } + } + // Database config if v1.Database != nil { if v1.Database.Driver != "" { @@ -1525,6 +1541,11 @@ func ConvertGlobalToV1ServerConfig(gc *GlobalConfig) *V1ServerConfig { }, } + v1.SystemProject = &V1SystemProjectConfig{ + Enabled: gc.SystemProject.Enabled, + WorkspacePath: gc.SystemProject.WorkspacePath, + } + // Database config v1.Database = &V1DatabaseConfig{ Driver: gc.Database.Driver, diff --git a/pkg/config/settings_v1_test.go b/pkg/config/settings_v1_test.go index dbc0778e6..af4b5d793 100644 --- a/pkg/config/settings_v1_test.go +++ b/pkg/config/settings_v1_test.go @@ -1531,6 +1531,8 @@ func TestConvertGlobalToV1ServerConfig_RoundTrip(t *testing.T) { gc.RuntimeBroker.Enabled = true gc.RuntimeBroker.BrokerID = "broker-abc" gc.RuntimeBroker.BrokerName = "test-broker" + gc.SystemProject.Enabled = true + gc.SystemProject.WorkspacePath = "/mnt/scion/system-project" gc.Database.Driver = "sqlite" gc.Auth.Enabled = true gc.Auth.Token = "test-token" @@ -1543,6 +1545,9 @@ func TestConvertGlobalToV1ServerConfig_RoundTrip(t *testing.T) { assert.Equal(t, true, v1.Broker.Enabled) assert.Equal(t, "broker-abc", v1.Broker.BrokerID) assert.Equal(t, "test-broker", v1.Broker.BrokerName) + require.NotNil(t, v1.SystemProject) + assert.True(t, v1.SystemProject.Enabled) + assert.Equal(t, "/mnt/scion/system-project", v1.SystemProject.WorkspacePath) assert.Equal(t, "sqlite", v1.Database.Driver) assert.Equal(t, true, v1.Auth.DevMode) assert.Equal(t, "test-token", v1.Auth.DevToken) @@ -1555,6 +1560,8 @@ func TestConvertGlobalToV1ServerConfig_RoundTrip(t *testing.T) { assert.Equal(t, gc.RuntimeBroker.Enabled, gc2.RuntimeBroker.Enabled) assert.Equal(t, gc.RuntimeBroker.BrokerID, gc2.RuntimeBroker.BrokerID) assert.Equal(t, gc.RuntimeBroker.BrokerName, gc2.RuntimeBroker.BrokerName) + assert.Equal(t, gc.SystemProject.Enabled, gc2.SystemProject.Enabled) + assert.Equal(t, gc.SystemProject.WorkspacePath, gc2.SystemProject.WorkspacePath) } func TestConvertGlobalToV1ServerConfig_Nil(t *testing.T) { diff --git a/pkg/hub/authz.go b/pkg/hub/authz.go index 7edba2f00..455a804e2 100644 --- a/pkg/hub/authz.go +++ b/pkg/hub/authz.go @@ -18,6 +18,7 @@ import ( "context" "errors" "log/slog" + "sync" "time" "github.com/GoogleCloudPlatform/scion/pkg/store" @@ -79,6 +80,10 @@ type EvaluationDetail struct { type AuthzService struct { store store.Store logger *slog.Logger + + systemProjectMu sync.Mutex + systemProjectID string + systemProjectIDCached bool } // NewAuthzService creates a new AuthzService. @@ -187,6 +192,13 @@ func (a *AuthzService) checkAccessForAgent(ctx context.Context, agent AgentIdent } } + if a.hasSystemProjectAdminDelegation(ctx, agent) { + return Decision{ + Allowed: true, + Reason: "system project admin delegation", + } + } + // 1. Build principal refs: direct agent + effective groups principals := []store.PrincipalRef{ {Type: "agent", ID: agent.ID()}, @@ -216,6 +228,45 @@ func (a *AuthzService) checkAccessForAgent(ctx context.Context, agent AgentIdent return a.checkDelegation(ctx, agent, resource, action, policies) } +func (a *AuthzService) getSystemProjectID(ctx context.Context) string { + a.systemProjectMu.Lock() + defer a.systemProjectMu.Unlock() + if a.systemProjectIDCached { + return a.systemProjectID + } + project, err := a.store.GetProjectBySlug(ctx, "system") + if err != nil || project == nil { + return "" + } + a.systemProjectID = project.ID + a.systemProjectIDCached = true + return a.systemProjectID +} + +func (a *AuthzService) isSystemProjectAgent(ctx context.Context, agent AgentIdentity) bool { + projectID := agent.ProjectID() + if projectID == "" { + return false + } + return projectID == a.getSystemProjectID(ctx) +} + +func (a *AuthzService) originUserIsCurrentAdmin(ctx context.Context, agent AgentIdentity) bool { + originUserID := agent.OriginUserID() + if originUserID == "" { + return false + } + user, err := a.store.GetUser(ctx, originUserID) + if err != nil || user == nil { + return false + } + return user.Role == "admin" && user.Status == "active" +} + +func (a *AuthzService) hasSystemProjectAdminDelegation(ctx context.Context, agent AgentIdentity) bool { + return a.isSystemProjectAgent(ctx, agent) && a.originUserIsCurrentAdmin(ctx, agent) +} + // checkDelegation handles the delegation fallback for agents. func (a *AuthzService) checkDelegation(ctx context.Context, agent AgentIdentity, resource Resource, action Action, _ []store.Policy) Decision { // Find policies with delegation conditions that match the resource diff --git a/pkg/hub/authz_integration_test.go b/pkg/hub/authz_integration_test.go index ae608796d..3aa8d074a 100644 --- a/pkg/hub/authz_integration_test.go +++ b/pkg/hub/authz_integration_test.go @@ -23,6 +23,7 @@ import ( "testing" "github.com/GoogleCloudPlatform/scion/pkg/agent/state" + "github.com/GoogleCloudPlatform/scion/pkg/projectcompat" "github.com/GoogleCloudPlatform/scion/pkg/store" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -184,6 +185,55 @@ func TestEvaluateEndpoint_AgentPolicy(t *testing.T) { assert.True(t, evalResp.Allowed) } +func TestEvaluateEndpoint_SystemProjectAgentUsesStoredAncestry(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + require.NoError(t, s.CreateUser(ctx, &store.User{ + ID: tid("eval-origin-admin"), + Email: "origin-admin@test.com", + DisplayName: "Origin Admin", + Role: store.UserRoleAdmin, + Status: "active", + })) + require.NoError(t, s.CreateProject(ctx, &store.Project{ + ID: tid("eval-system-project"), + Name: "System", + Slug: "system", + Labels: map[string]string{ + projectcompat.LabelSystemProject: "true", + }, + })) + require.NoError(t, s.CreateProject(ctx, &store.Project{ + ID: tid("eval-target-project"), + Name: "Target", + Slug: "target", + })) + require.NoError(t, s.CreateAgent(ctx, &store.Agent{ + ID: tid("eval-system-agent"), + Slug: tid("eval-system-agent"), + Name: "System Agent", + ProjectID: tid("eval-system-project"), + Phase: string(state.PhaseRunning), + Ancestry: []string{tid("eval-origin-admin")}, + })) + + evalReq := EvaluateRequest{ + PrincipalType: "agent", + PrincipalID: tid("eval-system-agent"), + ResourceType: "project", + ResourceID: tid("eval-target-project"), + Action: "delete", + } + rec := doRequest(t, srv, http.MethodPost, "/api/v1/policies/evaluate", evalReq) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var evalResp EvaluateResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&evalResp)) + assert.True(t, evalResp.Allowed) + assert.Equal(t, "system project admin delegation", evalResp.Reason) +} + func TestEvaluateEndpoint_AgentBinding(t *testing.T) { srv, s := testServer(t) ctx := context.Background() diff --git a/pkg/hub/authz_test.go b/pkg/hub/authz_test.go index 2831bf221..61bc6ce1b 100644 --- a/pkg/hub/authz_test.go +++ b/pkg/hub/authz_test.go @@ -23,6 +23,7 @@ import ( "time" "github.com/GoogleCloudPlatform/scion/pkg/agent/state" + "github.com/GoogleCloudPlatform/scion/pkg/projectcompat" "github.com/GoogleCloudPlatform/scion/pkg/store" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -698,3 +699,127 @@ func TestAuthz_AncestryAccess_NotInChain(t *testing.T) { decision := authz.CheckAccess(ctx, user, resource, ActionRead) assert.False(t, decision.Allowed) } + +func TestAuthz_SystemProjectAgentAdminDelegation(t *testing.T) { + authz, s := authzTestSetup(t) + ctx := context.Background() + requireSystemDelegationFixtures(t, ctx, s, "admin") + + agent := &evaluateAgentIdentity{ + id: tid("system-agent"), + projectID: tid("system-project"), + ancestry: []string{tid("origin-user")}, + } + resource := Resource{Type: "project", ID: tid("unrelated-project")} + + decision := authz.CheckAccess(ctx, agent, resource, ActionDelete) + assert.True(t, decision.Allowed) + assert.Equal(t, "system project admin delegation", decision.Reason) +} + +func TestAuthz_SystemProjectAgentDelegationRequiresCurrentAdmin(t *testing.T) { + authz, s := authzTestSetup(t) + ctx := context.Background() + requireSystemDelegationFixtures(t, ctx, s, "admin") + + agent := &evaluateAgentIdentity{ + id: tid("system-agent"), + projectID: tid("system-project"), + ancestry: []string{tid("origin-user")}, + } + resource := Resource{Type: "project", ID: tid("unrelated-project")} + assert.True(t, authz.CheckAccess(ctx, agent, resource, ActionDelete).Allowed) + + user, err := s.GetUser(ctx, tid("origin-user")) + require.NoError(t, err) + user.Role = "member" + require.NoError(t, s.UpdateUser(ctx, user)) + + decision := authz.CheckAccess(ctx, agent, resource, ActionDelete) + assert.False(t, decision.Allowed) + assert.Equal(t, "default deny", decision.Reason) +} + +func TestAuthz_SystemProjectAgentDelegationRequiresSystemProject(t *testing.T) { + authz, s := authzTestSetup(t) + ctx := context.Background() + requireSystemDelegationFixtures(t, ctx, s, "admin") + + normalProject := &store.Project{ID: tid("normal-project"), Name: "Normal", Slug: "normal"} + require.NoError(t, s.CreateProject(ctx, normalProject)) + agent := &evaluateAgentIdentity{ + id: tid("normal-agent"), + projectID: tid("normal-project"), + ancestry: []string{tid("origin-user")}, + } + + decision := authz.CheckAccess(ctx, agent, Resource{Type: "project", ID: tid("unrelated-project")}, ActionDelete) + assert.False(t, decision.Allowed) + assert.Equal(t, "default deny", decision.Reason) +} + +func TestAuthz_SystemProjectAgentNonAdminOriginDenied(t *testing.T) { + authz, s := authzTestSetup(t) + ctx := context.Background() + requireSystemDelegationFixtures(t, ctx, s, "member") + + agent := &evaluateAgentIdentity{ + id: tid("system-agent"), + projectID: tid("system-project"), + ancestry: []string{tid("origin-user")}, + } + resource := Resource{Type: "project", ID: tid("unrelated-project")} + + decision := authz.CheckAccess(ctx, agent, resource, ActionDelete) + assert.False(t, decision.Allowed) + assert.Equal(t, "default deny", decision.Reason) +} + +func TestAuthz_SystemProjectAgentSuspendedAdminDenied(t *testing.T) { + authz, s := authzTestSetup(t) + ctx := context.Background() + + require.NoError(t, s.CreateUser(ctx, &store.User{ + ID: tid("suspended-admin"), Email: "suspended@test.com", + DisplayName: "Suspended", Role: "admin", Status: "suspended", + })) + require.NoError(t, s.CreateProject(ctx, &store.Project{ + ID: tid("system-project"), Name: "System", Slug: "system", + Labels: map[string]string{projectcompat.LabelSystemProject: "true"}, + })) + + agent := &evaluateAgentIdentity{ + id: tid("system-agent"), + projectID: tid("system-project"), + ancestry: []string{tid("suspended-admin")}, + } + resource := Resource{Type: "project", ID: tid("any-project")} + + decision := authz.CheckAccess(ctx, agent, resource, ActionDelete) + assert.False(t, decision.Allowed) + assert.Equal(t, "default deny", decision.Reason) +} + +func requireSystemDelegationFixtures(t *testing.T, ctx context.Context, s store.Store, originRole string) { + t.Helper() + require.NoError(t, s.CreateUser(ctx, &store.User{ + ID: tid("origin-user"), + Email: "origin@test.com", + DisplayName: "Origin", + Role: originRole, + Status: "active", + })) + require.NoError(t, s.CreateProject(ctx, &store.Project{ + ID: tid("system-project"), + Name: "System", + Slug: "system", + Labels: map[string]string{ + projectcompat.LabelSystemProject: "true", + }, + })) + require.NoError(t, s.CreateProject(ctx, &store.Project{ + ID: tid("unrelated-project"), + Name: "Unrelated", + Slug: "unrelated", + })) +} diff --git a/pkg/hub/capabilities.go b/pkg/hub/capabilities.go index 49ee407c4..cfcbc86b9 100644 --- a/pkg/hub/capabilities.go +++ b/pkg/hub/capabilities.go @@ -261,6 +261,15 @@ func (a *AuthzService) ComputeCapabilitiesBatch(ctx context.Context, identity Id return caps } + if agent, ok := identity.(AgentIdentity); ok && a.hasSystemProjectAdminDelegation(ctx, agent) { + allCap := allActions(actions) + caps := make([]*Capabilities, len(resources)) + for i := range caps { + caps[i] = allCap + } + return caps + } + // Pre-fetch principals and policies once for the identity principals, policies := a.precomputeForIdentity(ctx, identity) @@ -350,6 +359,8 @@ func (a *AuthzService) precomputeForIdentity(ctx context.Context, identity Ident } // checkAccessPrecomputed evaluates access using pre-fetched principals and policies. +// NOTE: System-project admin delegation is handled at the batch level in ComputeCapabilitiesBatch, +// not here. Callers using this function directly will not get delegation behavior. func (a *AuthzService) checkAccessPrecomputed(identity Identity, _ []store.PrincipalRef, policies []store.Policy, resource Resource, action Action) Decision { // Owner bypass (already handled in batch caller, but kept for single-resource calls) if user, ok := identity.(UserIdentity); ok { diff --git a/pkg/hub/capabilities_test.go b/pkg/hub/capabilities_test.go index 2c8baff10..2ddfca983 100644 --- a/pkg/hub/capabilities_test.go +++ b/pkg/hub/capabilities_test.go @@ -109,6 +109,28 @@ func TestComputeCapabilitiesBatch_AdminGetsAll(t *testing.T) { } } +func TestComputeCapabilitiesBatch_SystemProjectAgentAdminDelegation(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + requireSystemDelegationFixtures(t, ctx, s, "admin") + + agent := &evaluateAgentIdentity{ + id: tid("system-agent-cap"), + projectID: tid("system-project"), + ancestry: []string{tid("origin-user")}, + } + resources := []Resource{ + {Type: "agent", ID: tid("agent-cap-1")}, + {Type: "agent", ID: tid("agent-cap-2")}, + } + + caps := srv.authzService.ComputeCapabilitiesBatch(ctx, agent, resources, "agent") + require.Len(t, caps, 2) + for _, cap := range caps { + assert.Equal(t, []string{"read", "update", "delete", "start", "stop", "message", "attach"}, cap.Actions) + } +} + func TestComputeCapabilitiesBatch_MixedOwnership(t *testing.T) { srv, s := testServer(t) ctx := context.Background() diff --git a/pkg/hub/handlers_policies.go b/pkg/hub/handlers_policies.go index b9a9e6459..679db9a5c 100644 --- a/pkg/hub/handlers_policies.go +++ b/pkg/hub/handlers_policies.go @@ -577,6 +577,7 @@ func (s *Server) handlePolicyEvaluate(w http.ResponseWriter, r *http.Request) { evalIdentity = &evaluateAgentIdentity{ id: agent.ID, projectID: agent.ProjectID, + ancestry: agent.Ancestry, } groupIDs, _ := s.store.GetEffectiveGroupsForAgent(ctx, agent.ID) effectiveGroups = groupIDs @@ -601,15 +602,32 @@ func (s *Server) handlePolicyEvaluate(w http.ResponseWriter, r *http.Request) { type evaluateAgentIdentity struct { id string projectID string + scopes []AgentTokenScope + ancestry []string } -func (e *evaluateAgentIdentity) ID() string { return e.id } -func (e *evaluateAgentIdentity) Type() string { return "agent" } -func (e *evaluateAgentIdentity) ProjectID() string { return e.projectID } -func (e *evaluateAgentIdentity) Scopes() []AgentTokenScope { return nil } -func (e *evaluateAgentIdentity) HasScope(AgentTokenScope) bool { return true } -func (e *evaluateAgentIdentity) Ancestry() []string { return nil } -func (e *evaluateAgentIdentity) OriginUserID() string { return "" } +func (e *evaluateAgentIdentity) ID() string { return e.id } +func (e *evaluateAgentIdentity) Type() string { return "agent" } +func (e *evaluateAgentIdentity) ProjectID() string { return e.projectID } +func (e *evaluateAgentIdentity) Scopes() []AgentTokenScope { return e.scopes } +func (e *evaluateAgentIdentity) HasScope(scope AgentTokenScope) bool { + if len(e.scopes) == 0 { + return true + } + for _, s := range e.scopes { + if s == scope { + return true + } + } + return false +} +func (e *evaluateAgentIdentity) Ancestry() []string { return e.ancestry } +func (e *evaluateAgentIdentity) OriginUserID() string { + if len(e.ancestry) == 0 { + return "" + } + return e.ancestry[0] +} // populateResourceContext fills in owner/parent info from the store. func populateResourceContext(ctx context.Context, s *Server, resource *Resource, resourceType, resourceID string) { diff --git a/pkg/hub/handlers_projects_core.go b/pkg/hub/handlers_projects_core.go index 7d204c1f5..4208f666c 100644 --- a/pkg/hub/handlers_projects_core.go +++ b/pkg/hub/handlers_projects_core.go @@ -32,6 +32,7 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/gcp" "github.com/GoogleCloudPlatform/scion/pkg/hubclient" "github.com/GoogleCloudPlatform/scion/pkg/labels" + "github.com/GoogleCloudPlatform/scion/pkg/projectcompat" "github.com/GoogleCloudPlatform/scion/pkg/secret" "github.com/GoogleCloudPlatform/scion/pkg/storage" "github.com/GoogleCloudPlatform/scion/pkg/store" @@ -263,6 +264,9 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { if req.ID != "" { existing, err := s.store.GetProject(ctx, req.ID) if err == nil { + if s.rejectReservedProjectSlugForNonAdmin(w, ctx, existing.Slug) { + return + } // Project already exists — ensure associated groups exist (backfill for // projects created before group support was added). Pass the caller // so they get added as an owner of the members group. @@ -288,9 +292,19 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { } baseSlug := req.Slug - if baseSlug == "" { + slugExplicit := baseSlug != "" + if !slugExplicit { baseSlug = api.Slugify(req.Name) } + if projectcompat.IsReservedProjectSlug(baseSlug) { + if slugExplicit { + if s.rejectReservedProjectSlugForNonAdmin(w, ctx, baseSlug) { + return + } + } else { + baseSlug = baseSlug + "-project" + } + } slug, err := s.store.NextAvailableSlug(ctx, baseSlug) if err != nil { @@ -303,6 +317,10 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { displayName = api.DisplayNameWithSerial(req.Name, slug, baseSlug) } + if s.rejectReservedLabelsForNonAdmin(w, ctx, req.Labels) { + return + } + // Apply workspace mode label for git projects with explicit workspace mode. if normalizedRemote != "" { switch req.WorkspaceMode { @@ -433,6 +451,17 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, project) } +func (s *Server) rejectReservedProjectSlugForNonAdmin(w http.ResponseWriter, ctx context.Context, slug string) bool { + if !projectcompat.IsReservedProjectSlug(slug) { + return false + } + if user := GetUserIdentityFromContext(ctx); user != nil && user.Role() == "admin" { + return false + } + Forbidden(w) + return true +} + // createProjectGroup creates the implicit project_agents group for a project. // This is a best-effort operation; failures are logged but don't fail the caller. // If the group already exists (e.g., project was deleted and recreated with the same @@ -1000,6 +1029,9 @@ func (s *Server) handleProjectRegister(w http.ResponseWriter, r *http.Request) { } baseSlug := api.Slugify(req.Name) + if projectcompat.IsReservedProjectSlug(baseSlug) { + baseSlug = baseSlug + "-project" + } slug, err := s.store.NextAvailableSlug(ctx, baseSlug) if err != nil { writeErrorFromErr(w, err, "") @@ -1011,6 +1043,10 @@ func (s *Server) handleProjectRegister(w http.ResponseWriter, r *http.Request) { displayName = api.DisplayNameWithSerial(req.Name, slug, baseSlug) } + if s.rejectReservedLabelsForNonAdmin(w, ctx, req.Labels) { + return + } + project = &store.Project{ ID: projectID, Name: displayName, @@ -2049,6 +2085,24 @@ func (s *Server) getProject(w http.ResponseWriter, r *http.Request, id string) { writeJSON(w, http.StatusOK, resp) } +func (s *Server) rejectReservedLabelsForNonAdmin(w http.ResponseWriter, ctx context.Context, labels map[string]string) bool { + if labels == nil { + return false + } + user := GetUserIdentityFromContext(ctx) + if user != nil && user.Role() == "admin" { + return false + } + for key := range labels { + if key == projectcompat.LabelScionSystem || key == projectcompat.LabelSystemProject { + writeError(w, http.StatusForbidden, ErrCodeForbidden, + "Reserved system labels cannot be set by non-admin users", nil) + return true + } + } + return false +} + func (s *Server) updateProject(w http.ResponseWriter, r *http.Request, id string) { ctx := r.Context() @@ -2092,6 +2146,9 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request, id string return } if newSlug != oldSlug { + if s.rejectReservedProjectSlugForNonAdmin(w, ctx, newSlug) { + return + } existing, err := s.store.GetProjectBySlug(ctx, newSlug) if err != nil && err != store.ErrNotFound { writeErrorFromErr(w, err, "") @@ -2106,6 +2163,9 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request, id string } } if updates.Labels != nil { + if s.rejectReservedLabelsForNonAdmin(w, ctx, updates.Labels) { + return + } project.Labels = updates.Labels } if updates.Visibility != "" { diff --git a/pkg/hub/handlers_test.go b/pkg/hub/handlers_test.go index b4359bf92..ca5d27e3f 100644 --- a/pkg/hub/handlers_test.go +++ b/pkg/hub/handlers_test.go @@ -2412,6 +2412,207 @@ func TestProjectCreateWithSlug(t *testing.T) { } } +func TestProjectCreateRejectsReservedSlugForNonAdmin(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + member := &store.User{ + ID: tid("reserved-member"), + Email: "reserved-member@test.com", + DisplayName: "Reserved Member", + Role: store.UserRoleMember, + Status: "active", + } + if err := s.CreateUser(ctx, member); err != nil { + t.Fatalf("failed to create member: %v", err) + } + + for _, slug := range []string{"system", "global"} { + body := CreateProjectRequest{Name: slug, Slug: slug} + rec := doRequestAsUser(t, srv, member, http.MethodPost, "/api/v1/projects", body) + if rec.Code != http.StatusForbidden { + t.Fatalf("member create %q: expected status 403, got %d: %s", slug, rec.Code, rec.Body.String()) + } + } + + adminBody := CreateProjectRequest{Name: "System", Slug: "system"} + rec := doRequest(t, srv, http.MethodPost, "/api/v1/projects", adminBody) + if rec.Code != http.StatusCreated { + t.Fatalf("admin create reserved slug: expected status 201, got %d: %s", rec.Code, rec.Body.String()) + } + + var project store.Project + if err := json.NewDecoder(rec.Body).Decode(&project); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if project.Slug != "system" { + t.Fatalf("expected system slug, got %q", project.Slug) + } +} + +func TestProjectUpdateRejectsReservedLabelsForNonAdmin(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + member := &store.User{ + ID: tid("label-member"), + Email: "label-member@test.com", + DisplayName: "Label Member", + Role: store.UserRoleMember, + Status: "active", + } + if err := s.CreateUser(ctx, member); err != nil { + t.Fatalf("failed to create member: %v", err) + } + + project := &store.Project{ + ID: tid("label-proj"), + Slug: "label-proj", + Name: "Label Project", + CreatedBy: member.ID, + Created: time.Now(), + Updated: time.Now(), + } + if err := s.CreateProject(ctx, project); err != nil { + t.Fatalf("failed to create project: %v", err) + } + + for _, labelKey := range []string{"scion.io/system-project", "scion.io/system"} { + body := map[string]interface{}{ + "labels": map[string]string{labelKey: "true"}, + } + rec := doRequestAsUser(t, srv, member, http.MethodPatch, + fmt.Sprintf("/api/v1/projects/%s", tid("label-proj")), body) + if rec.Code != http.StatusForbidden { + t.Fatalf("member set %q: expected status 403, got %d: %s", labelKey, rec.Code, rec.Body.String()) + } + } + + adminBody := map[string]interface{}{ + "labels": map[string]string{"scion.io/system-project": "true"}, + } + rec := doRequest(t, srv, http.MethodPatch, + fmt.Sprintf("/api/v1/projects/%s", tid("label-proj")), adminBody) + if rec.Code != http.StatusOK { + t.Fatalf("admin set system label: expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestProjectCreateRejectsReservedLabelsForNonAdmin(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + member := &store.User{ + ID: tid("create-label-member"), + Email: "create-label-member@test.com", + DisplayName: "Create Label Member", + Role: store.UserRoleMember, + Status: "active", + } + if err := s.CreateUser(ctx, member); err != nil { + t.Fatalf("failed to create member: %v", err) + } + + for _, labelKey := range []string{"scion.io/system-project", "scion.io/system"} { + body := map[string]interface{}{ + "name": "test-create-" + labelKey, + "labels": map[string]string{labelKey: "true"}, + } + rec := doRequestAsUser(t, srv, member, http.MethodPost, "/api/v1/projects", body) + if rec.Code != http.StatusForbidden { + t.Fatalf("member create with %q: expected status 403, got %d: %s", labelKey, rec.Code, rec.Body.String()) + } + } + + adminBody := map[string]interface{}{ + "name": "admin-system-project", + "labels": map[string]string{"scion.io/system-project": "true"}, + } + rec := doRequest(t, srv, http.MethodPost, "/api/v1/projects", adminBody) + if rec.Code != http.StatusOK && rec.Code != http.StatusCreated { + t.Fatalf("admin create with system label: expected status 200/201, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestProjectRegisterRejectsReservedLabelsForNonAdmin(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + member := &store.User{ + ID: tid("register-label-member"), + Email: "register-label-member@test.com", + DisplayName: "Register Label Member", + Role: store.UserRoleMember, + Status: "active", + } + if err := s.CreateUser(ctx, member); err != nil { + t.Fatalf("failed to create member: %v", err) + } + + for _, labelKey := range []string{"scion.io/system-project", "scion.io/system"} { + body := map[string]interface{}{ + "name": "test-register-" + labelKey, + "labels": map[string]string{labelKey: "true"}, + } + rec := doRequestAsUser(t, srv, member, http.MethodPost, "/api/v1/projects/register", body) + if rec.Code != http.StatusForbidden { + t.Fatalf("member register with %q: expected status 403, got %d: %s", labelKey, rec.Code, rec.Body.String()) + } + } +} + +func TestProjectUpdateRejectsReservedSlugRenameForNonAdmin(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + member := &store.User{ + ID: tid("slug-member"), + Email: "slug-member@test.com", + DisplayName: "Slug Member", + Role: store.UserRoleMember, + Status: "active", + } + if err := s.CreateUser(ctx, member); err != nil { + t.Fatalf("failed to create member: %v", err) + } + + project := &store.Project{ + ID: tid("slug-proj"), + Slug: "my-project", + Name: "My Project", + CreatedBy: member.ID, + Created: time.Now(), + Updated: time.Now(), + } + if err := s.CreateProject(ctx, project); err != nil { + t.Fatalf("failed to create project: %v", err) + } + + for _, slug := range []string{"system", "global"} { + body := map[string]interface{}{"slug": slug} + rec := doRequestAsUser(t, srv, member, http.MethodPatch, + fmt.Sprintf("/api/v1/projects/%s", tid("slug-proj")), body) + if rec.Code != http.StatusForbidden { + t.Fatalf("member rename to %q: expected status 403, got %d: %s", slug, rec.Code, rec.Body.String()) + } + } + + adminBody := map[string]interface{}{"slug": "system"} + rec := doRequest(t, srv, http.MethodPatch, + fmt.Sprintf("/api/v1/projects/%s", tid("slug-proj")), adminBody) + if rec.Code != http.StatusOK { + t.Fatalf("admin rename to system: expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp store.Project + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Slug != "system" { + t.Fatalf("expected slug %q, got %q", "system", resp.Slug) + } +} + // ============================================================================ // Project Rename Tests // ============================================================================ diff --git a/pkg/hub/httpdispatcher.go b/pkg/hub/httpdispatcher.go index 07bfc5ef6..4b767c43c 100644 --- a/pkg/hub/httpdispatcher.go +++ b/pkg/hub/httpdispatcher.go @@ -27,6 +27,7 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/api" "github.com/GoogleCloudPlatform/scion/pkg/messages" "github.com/GoogleCloudPlatform/scion/pkg/observability/dispatchmetrics" + "github.com/GoogleCloudPlatform/scion/pkg/projectcompat" "github.com/GoogleCloudPlatform/scion/pkg/secret" "github.com/GoogleCloudPlatform/scion/pkg/store" "github.com/go-jose/go-jose/v4/jwt" @@ -646,6 +647,11 @@ func (d *HTTPAgentDispatcher) buildCreateRequest(ctx context.Context, agent *sto } } + if req.ResolvedEnv == nil { + req.ResolvedEnv = make(map[string]string) + } + d.applyCLIModeForAgentProject(ctx, agent, req.ResolvedEnv) + return req, nil } @@ -710,6 +716,34 @@ func (d *HTTPAgentDispatcher) resolveDispatchProjectInfo(ctx context.Context, ag return info } +func isSystemProject(project *store.Project) bool { + return project != nil && project.Labels[projectcompat.LabelSystemProject] == "true" +} + +func (d *HTTPAgentDispatcher) cliModeForAgentProject(ctx context.Context, agent *store.Agent) string { + if agent == nil || agent.ProjectID == "" { + return "agent" + } + project, err := d.store.GetProject(ctx, agent.ProjectID) + if err != nil { + return "agent" + } + if isSystemProject(project) { + return "assistant" + } + return "agent" +} + +func (d *HTTPAgentDispatcher) applyCLIModeForAgentProject(ctx context.Context, agent *store.Agent, resolvedEnv map[string]string) { + if resolvedEnv == nil { + return + } + mode := d.cliModeForAgentProject(ctx, agent) + if mode == "assistant" || resolvedEnv["SCION_CLI_MODE"] == "" { + resolvedEnv["SCION_CLI_MODE"] = mode + } +} + // applyBrokerResponse updates agent fields from the broker's response. func (d *HTTPAgentDispatcher) applyBrokerResponse(agent *store.Agent, resp *RemoteAgentResponse) { if resp.Agent != nil { @@ -1277,6 +1311,8 @@ func (d *HTTPAgentDispatcher) DispatchAgentStart(ctx context.Context, agent *sto ) } + d.applyCLIModeForAgentProject(ctx, agent, resolvedEnv) + // Use agent name as identifier (runtime broker uses name or ID) // Pass the agent's harness config so the broker starts with the correct harness. harnessConfig := "" @@ -1396,6 +1432,8 @@ func (d *HTTPAgentDispatcher) DispatchAgentRestart(ctx context.Context, agent *s } } + d.applyCLIModeForAgentProject(ctx, agent, resolvedEnv) + err = d.client.RestartAgent(ctx, agent.RuntimeBrokerID, endpoint, agent.Slug, agent.ProjectID, resolvedEnv) if errors.Is(err, ErrLifecycleDeferred) { return d.deferredRestart(ctx, agent) diff --git a/pkg/hub/httpdispatcher_test.go b/pkg/hub/httpdispatcher_test.go index da6dfb77f..6c987a165 100644 --- a/pkg/hub/httpdispatcher_test.go +++ b/pkg/hub/httpdispatcher_test.go @@ -29,6 +29,7 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/agent/state" "github.com/GoogleCloudPlatform/scion/pkg/api" "github.com/GoogleCloudPlatform/scion/pkg/messages" + "github.com/GoogleCloudPlatform/scion/pkg/projectcompat" "github.com/GoogleCloudPlatform/scion/pkg/secret" "github.com/GoogleCloudPlatform/scion/pkg/store" ) @@ -134,6 +135,7 @@ func (m *mockRuntimeBrokerClient) RestartAgent(ctx context.Context, brokerID, br m.lastBrokerID = brokerID m.lastEndpoint = brokerEndpoint m.lastAgentID = agentID + m.lastResolvedEnv = resolvedEnv return m.returnErr } @@ -257,6 +259,162 @@ func TestHTTPAgentDispatcher_DispatchAgentCreate(t *testing.T) { } } +func TestHTTPAgentDispatcher_SystemProjectCreateUsesAssistantCLIMode(t *testing.T) { + ctx := context.Background() + memStore := createTestStore(t) + requireProjectAndBroker(t, ctx, memStore, true) + + mockClient := &mockRuntimeBrokerClient{} + dispatcher := NewHTTPAgentDispatcherWithClient(memStore, mockClient, false, slog.Default()) + + agent := &store.Agent{ + ID: tid("agent-system"), + Name: "system-agent", + Slug: "system-agent", + ProjectID: tid("project-system"), + RuntimeBrokerID: tid("broker-cli-mode"), + AppliedConfig: &store.AgentAppliedConfig{ + Env: map[string]string{"SCION_CLI_MODE": "agent"}, + }, + } + + if err := dispatcher.DispatchAgentCreate(ctx, agent); err != nil { + t.Fatalf("DispatchAgentCreate failed: %v", err) + } + if got := mockClient.lastCreateReq.ResolvedEnv["SCION_CLI_MODE"]; got != "assistant" { + t.Fatalf("SCION_CLI_MODE = %q, want assistant", got) + } +} + +func TestHTTPAgentDispatcher_SystemProjectRestartUsesAssistantCLIMode(t *testing.T) { + ctx := context.Background() + memStore := createTestStore(t) + requireProjectAndBroker(t, ctx, memStore, true) + + mockClient := &mockRuntimeBrokerClient{} + dispatcher := NewHTTPAgentDispatcherWithClient(memStore, mockClient, false, slog.Default()) + + agent := &store.Agent{ + ID: tid("agent-system-restart"), + Name: "system-agent", + Slug: "system-agent", + ProjectID: tid("project-system"), + RuntimeBrokerID: tid("broker-cli-mode"), + } + + if err := dispatcher.DispatchAgentRestart(ctx, agent); err != nil { + t.Fatalf("DispatchAgentRestart failed: %v", err) + } + if got := mockClient.lastResolvedEnv["SCION_CLI_MODE"]; got != "assistant" { + t.Fatalf("SCION_CLI_MODE = %q, want assistant", got) + } +} + +func TestHTTPAgentDispatcher_SystemProjectStartUsesAssistantCLIMode(t *testing.T) { + ctx := context.Background() + memStore := createTestStore(t) + requireProjectAndBroker(t, ctx, memStore, true) + + mockClient := &mockRuntimeBrokerClient{} + dispatcher := NewHTTPAgentDispatcherWithClient(memStore, mockClient, false, slog.Default()) + + agent := &store.Agent{ + ID: tid("agent-system-start"), + Name: "system-agent", + Slug: "system-agent", + ProjectID: tid("project-system"), + RuntimeBrokerID: tid("broker-cli-mode"), + AppliedConfig: &store.AgentAppliedConfig{ + Env: map[string]string{"SCION_CLI_MODE": "agent"}, + }, + } + + if err := dispatcher.DispatchAgentStart(ctx, agent, "", false); err != nil { + t.Fatalf("DispatchAgentStart failed: %v", err) + } + if got := mockClient.lastResolvedEnv["SCION_CLI_MODE"]; got != "assistant" { + t.Fatalf("SCION_CLI_MODE = %q, want assistant", got) + } +} + +func TestHTTPAgentDispatcher_NormalProjectStartPreservesExplicitCLIMode(t *testing.T) { + ctx := context.Background() + memStore := createTestStore(t) + requireProjectAndBroker(t, ctx, memStore, false) + + mockClient := &mockRuntimeBrokerClient{} + dispatcher := NewHTTPAgentDispatcherWithClient(memStore, mockClient, false, slog.Default()) + + agent := &store.Agent{ + ID: tid("agent-normal"), + Name: "normal-agent", + Slug: "normal-agent", + ProjectID: tid("project-system"), + RuntimeBrokerID: tid("broker-cli-mode"), + AppliedConfig: &store.AgentAppliedConfig{ + Env: map[string]string{"SCION_CLI_MODE": "human"}, + }, + } + + if err := dispatcher.DispatchAgentStart(ctx, agent, "", false); err != nil { + t.Fatalf("DispatchAgentStart failed: %v", err) + } + if got := mockClient.lastResolvedEnv["SCION_CLI_MODE"]; got != "human" { + t.Fatalf("SCION_CLI_MODE = %q, want human", got) + } +} + +func TestHTTPAgentDispatcher_NormalProjectCreateDefaultsAgentCLIMode(t *testing.T) { + ctx := context.Background() + memStore := createTestStore(t) + requireProjectAndBroker(t, ctx, memStore, false) + + mockClient := &mockRuntimeBrokerClient{} + dispatcher := NewHTTPAgentDispatcherWithClient(memStore, mockClient, false, slog.Default()) + + agent := &store.Agent{ + ID: tid("agent-normal-default"), + Name: "normal-agent", + Slug: "normal-agent", + ProjectID: tid("project-system"), + RuntimeBrokerID: tid("broker-cli-mode"), + } + + if err := dispatcher.DispatchAgentCreate(ctx, agent); err != nil { + t.Fatalf("DispatchAgentCreate failed: %v", err) + } + if got := mockClient.lastCreateReq.ResolvedEnv["SCION_CLI_MODE"]; got != "agent" { + t.Fatalf("SCION_CLI_MODE = %q, want agent", got) + } +} + +func requireProjectAndBroker(t *testing.T, ctx context.Context, s store.Store, systemProject bool) { + t.Helper() + labels := map[string]string{} + if systemProject { + labels[projectcompat.LabelSystemProject] = "true" + } + project := &store.Project{ + ID: tid("project-system"), + Name: "test-project", + Slug: "test-project", + Labels: labels, + } + if err := s.CreateProject(ctx, project); err != nil { + t.Fatalf("failed to create project: %v", err) + } + broker := &store.RuntimeBroker{ + ID: tid("broker-cli-mode"), + Name: "test-broker", + Slug: "test-broker", + Endpoint: "http://localhost:9800", + Status: store.BrokerStatusOnline, + } + if err := s.CreateRuntimeBroker(ctx, broker); err != nil { + t.Fatalf("failed to create runtime broker: %v", err) + } +} + func TestHTTPAgentDispatcher_DispatchAgentStop(t *testing.T) { ctx := context.Background() memStore := createTestStore(t) diff --git a/pkg/projectcompat/labels.go b/pkg/projectcompat/labels.go index d2acd8476..bd70b1ffc 100644 --- a/pkg/projectcompat/labels.go +++ b/pkg/projectcompat/labels.go @@ -16,6 +16,25 @@ package projectcompat import "strings" +const ( + LabelScionSystem = "scion.io/system" + LabelSystemProject = "scion.io/system-project" +) + +const ( + ReservedProjectSlugGlobal = "global" + ReservedProjectSlugSystem = "system" +) + +func IsReservedProjectSlug(slug string) bool { + switch strings.ToLower(strings.TrimSpace(slug)) { + case ReservedProjectSlugGlobal, ReservedProjectSlugSystem: + return true + default: + return false + } +} + func ProjectIDFromLabels(labels map[string]string) string { if labels == nil { return "" diff --git a/pkg/runtimebroker/controlchannel.go b/pkg/runtimebroker/controlchannel.go index 2b09d83e9..d6c23d00b 100644 --- a/pkg/runtimebroker/controlchannel.go +++ b/pkg/runtimebroker/controlchannel.go @@ -239,10 +239,6 @@ func (c *ControlChannelClient) doConnect() error { } c.conn = conn - c.mu.Lock() - c.connected = true - c.connectedAt = time.Now() - c.mu.Unlock() // Send connect message connectMsg := wsprotocol.NewConnectMessage(c.config.BrokerID, c.config.Version, c.config.Projects) @@ -257,6 +253,11 @@ func (c *ControlChannelClient) doConnect() error { return fmt.Errorf("connection handshake failed: %w", err) } + c.mu.Lock() + c.connected = true + c.connectedAt = time.Now() + c.mu.Unlock() + c.log.Info("Connected to Hub control channel", "sessionID", c.sessionID) return nil }