diff --git a/.design/project-visibility-design-note.md b/.design/project-visibility-design-note.md new file mode 100644 index 0000000000..e9ae2f0a49 --- /dev/null +++ b/.design/project-visibility-design-note.md @@ -0,0 +1,46 @@ +## Design Note: Project Access via Membership (replacing dead visibility field) + +**From:** proj-visibility-dev +**Context:** The project visibility field (private/team/public) has been eradicated -- it was dead code re-introduced by a stale-branch merge. This note proposes the net-new mechanism to capture the same intent using the current role/group/policy model. + +### How it works today + +1. hub-member-read-all policy grants read+list on ResourceType:"*" to the hub-members group -- every authenticated user can see every project. +2. Per-project project::member-create-agents policy grants create, stop_all, message on agents, bound to the project::members group. +3. isProjectOwnerOrAdmin bypass (via RoleBindings) gates admin/owner actions. + +### Proposed changes -- using existing Policy system (group-aware, fully wired) + +Step 1: Narrow the global read-all grant. Replace the ResourceType:"*" wildcard in hub-member-read-all with explicit per-type allows for directory/catalog types only: +- KEEP globally readable: user, group, template, harness_config, broker, runtime_broker, gcp_service_account, policy, skill, quota, role, role_binding, hub +- GATE (remove from global read): project, agent + +After this, a project is only visible to users who have a matching policy grant. + +Step 2: Add project-scoped read policy. Modify createProjectMembersGroupAndPolicy to also seed a policy granting read, list on project and agent resource types, bound to the project::members group. Members can see the project and its agents. + +Step 3: Three access levels emerge from membership: +- Private (default): Only owner + explicitly added members. Members group has only the creator. +- Team: Collaborators added to the members group (users and/or nested groups). +- Everyone: Add hub-members group to the project's members group. The project-scoped read policy then applies to all hub users transitively. + +No creation-time selector needed. The Members panel IS the access control surface. + +Step 4: Enforcement gaps to close: +- getProject (single GET) -- add CheckAccess(ActionRead) gate +- listProjects/listAgents -- fail closed on nil identity (empty/401) + +Step 5: Members-card hint. Add hint text to the group member editor when it is a project members group: "To make this project visible to all hub users, add the hub-members group." + +### Role tiers (subtractive approach, no policy-engine changes) +- member = read-only (from the project-scoped read policy bound to members group) +- admin/owner = create/manage agents (from isProjectOwnerOrAdmin RoleBinding bypass -- already works) +- Existing members bumped to admin in one-time backfill to preserve current create-agent ability. + +### Known gaps (not blockers, flagging for awareness) +1. RoleBinding sync gap: Users added via groups API dont get a corresponding RoleBinding until hub restart. +2. Governance role not carried through group expansion: GetEffectiveGroups drops role. A user who is admin only via a nested group gets read but not create. +3. No policy management UI: Per-project policy changes happen programmatically. + +### Implementation approach +This is a direct extension of existing patterns -- the per-project members group exists, hub-members auto-enrollment works, and the Policy systems group-aware enforcement is production-proven. Low-risk, all existing infrastructure being wired together. Will proceed unless you flag concerns. diff --git a/changelog/2026-08-29-changelog.md b/changelog/2026-08-29-changelog.md new file mode 100644 index 0000000000..aa1b882ca0 --- /dev/null +++ b/changelog/2026-08-29-changelog.md @@ -0,0 +1,36 @@ +# Release Notes (2026-08-29) + +Per-agent messaging authorization ships end-to-end (D1-D10 message modes, conversation model at handler ingress, CLI grammar, admin UI), a credential-leak prevention sweep removes secrets from error output, argv, and deploy scripts, and the conversation model advances through tranches C4-C6 with dual-write for webchat topics and reachability guards. + +## 🔒 Security +* **Credential leak prevention (#1364, #1387, #1385, #1383):** Runtime error and debug paths no longer expose secrets — argv and env redacted from sandbox error output, access token removed from curl's argv in deploy.sh, and agent secrets now fetched from the hub at init rather than passed via command-line arguments. +* **Caller-supplied conversation_id authorization (#1403):** Server now authorizes conversation_id values supplied by callers, preventing unauthorized access to conversations by ID guessing. +* **Broker inbound sender authorization unified (#1411):** All broker inbound senders authorized through a single uniform path, eliminating inconsistencies across entry points. +* **@email path validation (#1407):** ResolveConversation now validates @email paths before processing, preventing malformed email addresses from reaching the resolver. + +## 🚀 Features +* **Per-agent messaging authorization (D1-D10) (#1371, #1382, #1374):** Complete message-mode system with per-agent authorization controls, conversation envelope/delivery/validation library, and admin UI with mode badges, reachability indicators, mode controls, and templates. +* **Conversation model at handler ingress (#1391, #1380, #1381):** Conversation model enforced at handler ingress with authz reachability guard (C5), webchat topics dual-written into the conversation model (C4), and CLI conversation-reference grammar with flag deprecation (C6). +* **Per-type UAT manage scope aliases (#1404):** Adds manage scope aliases per resource type, reducing the number of individual scopes users need to remember. +* **Agent env var provenance classification (#1384):** Each agent environment variable now carries provenance metadata (hub-injected, user-defined, runtime-derived), enabling audit and debugging of variable origins. +* **Permissions UI polish (#1395, #1394, #1396):** Role-binding display names with reusable principal picker, view-permissions modal for roles, and admin nav visibility fixed for plain members with hub-admin route guard. +* **CountUnbackfilledMessages store method (#1373):** New MessageStore method to count messages needing backfill, supporting the conversation model migration. + +## 🐛 Fixes +* **Race condition guards (#1409, #1405):** channelRegistry, pluginManager, dispatcher, and webChatStore reads now guarded with s.mu, fixing data races surfaced by the new nightly race detection job. +* **Guard scope and cross-project sentinel defects (#1379):** Closed DEF-39b and DEF-40 — guard scope was too narrow in some paths, and cross-project sentinels could leak. +* **Legacy-pending sentinel removed (#1401):** Removed the legacy-pending sentinel and split the validation choke point, cleaning up a source of confusion in the messaging pipeline (DEF-41). +* **Backfill 'message' action into existing policies (#1377):** Existing project member policies now include the 'message' action, ensuring messaging works for projects created before the authorization system. +* **Agent token file write check (#1386):** Agent refuses to start when the token file cannot be written, failing fast instead of silently running without credentials. +* **CLI precondition ordering (#1402):** Preconditions hoisted above conversation resolve, preventing confusing errors when prerequisites are missing (DEF-48). +* **Extras module repair (#1398):** Six broken modules in extras/ repaired with a new CI gate to prevent future breakage. +* **Hook response contract (#1400):** Data-driven hook response contract for antigravity, replacing ad-hoc response parsing. +* **Harness provider routing (#1397):** OpenCode vertex-ai provider routing fixed — GITHUB_TOKEN collision no longer breaks routing. + +## 🔧 CI & Infrastructure +* **Nightly race detection (#1408):** New CI job runs tests with `-race` flag nightly, surfacing data races before they hit production. +* **Full test suite reporting job (#1388, #1392):** Reporting-only full test suite job added; pipefail ensures the test step reports the real exit code. +* **Authz gate hardening (#1410, #1376, #1372):** AST validation added for DEF-50/DEF-37/DEF-56 authz gates; security marker gate updated for authorizeAgentMessage; conversation upsert guard widened for kind='group' topic mints. + +## 📖 Docs +* **Message mode glossary (#1389):** Glossary entries added for message modes and related messaging authorization terminology. diff --git a/cmd/hub.go b/cmd/hub.go index 608b23e8e7..22dc05f478 100644 --- a/cmd/hub.go +++ b/cmd/hub.go @@ -258,10 +258,9 @@ Examples: } var ( - hubProjectCreateSlug string - hubProjectCreateName string - hubProjectCreateBranch string - hubProjectCreateVisibility string + hubProjectCreateSlug string + hubProjectCreateName string + hubProjectCreateBranch string ) // hubProjectCreateCmd creates a project on the Hub from a git URL @@ -341,7 +340,6 @@ func init() { hubProjectCreateCmd.Flags().StringVar(&hubProjectCreateSlug, "slug", "", "Override the auto-derived slug") hubProjectCreateCmd.Flags().StringVar(&hubProjectCreateName, "name", "", "Human-friendly display name (defaults to repo name)") hubProjectCreateCmd.Flags().StringVar(&hubProjectCreateBranch, "branch", "", "Base branch for the project (defaults to detected default branch, or main)") - hubProjectCreateCmd.Flags().StringVar(&hubProjectCreateVisibility, "visibility", "", "Project visibility: private, team, or public (default: private)") hubProjectCreateCmd.Flags().BoolVar(&hubOutputJSON, "json", false, "Output in JSON format") // Also link flags to the hidden alias subcommands so they work too @@ -351,7 +349,6 @@ func init() { hubGrovesCreateCmd.Flags().StringVar(&hubProjectCreateSlug, "slug", "", "Override the auto-derived slug") hubGrovesCreateCmd.Flags().StringVar(&hubProjectCreateName, "name", "", "Human-friendly display name (defaults to repo name)") hubGrovesCreateCmd.Flags().StringVar(&hubProjectCreateBranch, "branch", "", "Base branch for the project (defaults to detected default branch, or main)") - hubGrovesCreateCmd.Flags().StringVar(&hubProjectCreateVisibility, "visibility", "", "Project visibility: private, team, or public (default: private)") hubGrovesCreateCmd.Flags().BoolVar(&hubOutputJSON, "json", false, "Output in JSON format") // Broker subcommand flags @@ -1326,7 +1323,6 @@ func runHubProjectsInfo(cmd *cobra.Command, args []string) error { "name": project.Name, "slug": project.Slug, "gitRemote": project.GitRemote, - "visibility": project.Visibility, "agentCount": project.AgentCount, "created": project.Created, "updated": project.Updated, @@ -1354,7 +1350,6 @@ func runHubProjectsInfo(cmd *cobra.Command, args []string) error { if project.GitRemote != "" { fmt.Printf("Git Remote: %s\n", project.GitRemote) } - fmt.Printf("Visibility: %s\n", valueOrDefault(project.Visibility, "private")) fmt.Printf("Agents: %d\n", project.AgentCount) fmt.Printf("Created: %s\n", project.Created.Format(time.RFC3339)) if !project.Updated.IsZero() && project.Updated != project.Created { @@ -1607,10 +1602,9 @@ func runHubProjectCreate(cmd *cobra.Command, args []string) error { // Create project on the hub (server assigns ID) project, err := client.Projects().Create(ctx, &hubclient.CreateProjectRequest{ - Name: displayName, - Slug: slug, - GitRemote: normalized, - Visibility: hubProjectCreateVisibility, + Name: displayName, + Slug: slug, + GitRemote: normalized, Labels: map[string]string{ "scion.dev/default-branch": defaultBranch, "scion.dev/clone-url": util.ToHTTPSCloneURL(gitURL), diff --git a/cmd/server_broker.go b/cmd/server_broker.go index 62f8ecb7d0..e21724e3e0 100644 --- a/cmd/server_broker.go +++ b/cmd/server_broker.go @@ -41,10 +41,9 @@ func registerGlobalProjectAndBroker(ctx context.Context, s store.Store, brokerID projectNeedsDefaultBroker := false if globalProject == nil { globalProject = &store.Project{ - ID: api.NewUUID(), - Name: "Global", - Slug: GlobalProjectName, - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "Global", + Slug: GlobalProjectName, Labels: map[string]string{ "scion.io/system": "true", "scion.io/global": "true", diff --git a/docs-site/src/content/docs/glossary.md b/docs-site/src/content/docs/glossary.md index cd5e04defd..f767d2fe43 100644 --- a/docs-site/src/content/docs/glossary.md +++ b/docs-site/src/content/docs/glossary.md @@ -168,9 +168,30 @@ A seeded system or custom limit configuration that defines a quota boundary with ## Messaging +### Branch mode (message mode) +A message mode that permits messaging from ancestry users (like lineage) plus the agent's direct parent and child agents. Project owners can pierce branch mode. + +### Lineage mode (message mode) +A message mode that restricts messaging to users in the agent's ancestry chain — the creating user and their ancestors. No agent-to-agent messaging is permitted for lineage-mode agents. Project owners can pierce lineage mode. + +### Message Mode +A per-agent setting that controls which actors (users and agents) can send messages to that agent. One of four values: `none`, `lineage`, `branch`, or `project` (the default). Set by the agent's owner or a project admin via the `set_message_mode` action; changeable at any time with immediate effect. Stored on the agent record as `message_mode`. + +### Messageability +A server-computed assessment of whether a specific viewer can message a specific agent, considering the agent's message mode, the viewer's identity, ancestry relationship, and permissions. Exposed in API responses as `_messageability` with `canMessage` and `canReachViewer` booleans. Used by the UI to gate message buttons and show reachability indicators. + ### Native Web Chat The built-in interactive messaging interface in the Web Dashboard (enabled via the `web.native_chat` feature flag) that promotes chat to a top-level fourth ShellType (alongside standalone, profile, and app). It features a dedicated thread rail, unread indicators, three-state visibility filtering (Conversation/Verbose/Full), @-mention autocomplete, and cross-channel reply coherence. +### None mode (message mode) +A message mode that seals the agent from all messaging except system-plane notices and super-admin piercing. No users and no agents can message a none-mode agent through normal paths. + +### Piercing (message authorization) +The ability of a privileged user to bypass an agent's message mode restrictions. Super-admins pierce all modes including none. Project owners pierce lineage and branch modes. Piercing applies only to user identities — it is never inherited by an owner's agents. + +### Project mode (message mode) +The default message mode. Any user with the `agent:message` permission in the project scope can message the agent, and any same-project agent in project or branch mode can message it. The most permissive mode. + ### Message Group A set of recipients addressed by a single send, correlated by a shared `group_id`, as opposed to a direct message to one recipient or a broadcast to all agents in a project. Distinct from **Group** (Hub users). diff --git a/docs-site/src/content/docs/hosted/user/messaging.md b/docs-site/src/content/docs/hosted/user/messaging.md index 14cb1cfca1..3dbea451ee 100644 --- a/docs-site/src/content/docs/hosted/user/messaging.md +++ b/docs-site/src/content/docs/hosted/user/messaging.md @@ -185,6 +185,23 @@ When an agent uses the `ask_user` tool (or similar mechanism depending on the ha Messages are delivered in real-time to the Web Dashboard via Server-Sent Events (SSE). The **Messages Tab** on the individual agent detail page provides a real-time stream of all communication with that specific agent. +## Message Authorization & Modes + +Every agent is protected by a **Message Mode** that controls which users and other agents can send messages to it. An agent's message mode can be set via the Web Dashboard or via the `set_message_mode` action. The available modes are: + +- **Project Mode (Default)**: Any user with the `agent:message` permission in the project can message the agent. Any peer agent in the project (that is not restricted by lineage mode) can also message it. The most permissive mode. +- **Branch Mode**: Only users in the agent's ancestry chain (its creator and their ancestors), plus the agent's direct parent and child agents, can message it. +- **Lineage Mode**: Strictly restricts messaging to users in the agent's ancestry chain (its creator and their ancestors). No agent-to-agent messaging is permitted. +- **None Mode**: Seals the agent from all messaging except system-plane notices. No users and no agents can message a none-mode agent through normal paths. + +### Piercing +Highly privileged users can bypass an agent's message mode restrictions. This is called **piercing**. +- **Project Owners** can pierce Branch and Lineage modes. +- **Super-admins** pierce all modes, including None mode. +Piercing applies only to user identities — it is never inherited by an owner's agents. + +The Web Dashboard displays reachability indicators (e.g., whether you can message a specific agent) based on the computed messageability, which takes into account the agent's mode, your ancestry relationship to it, and any piercing privileges. + --- ## Developer Guide & Best Practices diff --git a/docs-site/src/content/docs/hosted/user/personal-access-tokens.md b/docs-site/src/content/docs/hosted/user/personal-access-tokens.md index d799e97c92..b4ee422a96 100644 --- a/docs-site/src/content/docs/hosted/user/personal-access-tokens.md +++ b/docs-site/src/content/docs/hosted/user/personal-access-tokens.md @@ -44,8 +44,9 @@ permissions). Available scopes: | `agent:attach` | Attach to agent sessions | | `agent:dispatch` | Dispatch agents (create + start) | | `agent:manage` | All agent scopes (convenience alias) | +| `project:manage` | All project scopes (convenience alias) | -In addition to project and agent scopes, Scion supports UAT scopes for 7 other resource types: `skill`, `template`, `harness_config`, `group`, `user`, `broker`, and `gcp_service_account`. +In addition to project and agent scopes, Scion supports UAT scopes for 7 other resource types: `skill`, `template`, `harness_config`, `group`, `user`, `broker`, and `gcp_service_account`. Each resource type provides a `*:manage` convenience alias (e.g., `skill:manage`, `template:manage`) that grants all available actions for that resource. You can dynamically discover all available scopes and their descriptions by querying the API: ```bash diff --git a/docs-site/src/content/docs/hosted/user/secrets.md b/docs-site/src/content/docs/hosted/user/secrets.md index 7f8e4f449b..c942cb1c74 100644 --- a/docs-site/src/content/docs/hosted/user/secrets.md +++ b/docs-site/src/content/docs/hosted/user/secrets.md @@ -73,7 +73,7 @@ Enabling progeny propagation dynamically registers implicit access policies (e.g ## Managing Environment Variables -Use the `scion hub env` command suite to manage non-sensitive configuration. +Use the `scion hub env` command suite to manage non-sensitive configuration. Each agent environment variable carries **provenance metadata** (hub-injected, user-defined, or runtime-derived) to help you audit and debug the origin of specific values. ### Setting Variables ```bash diff --git a/internal/fixturegen/spec.go b/internal/fixturegen/spec.go index 6866f6b265..cd25348efe 100644 --- a/internal/fixturegen/spec.go +++ b/internal/fixturegen/spec.go @@ -82,7 +82,7 @@ func Spec() []TableFixture { "git_remote": "https://github.com/example/platform.git", "labels": unicodeJSON, "annotations": `{"note":"primary"}`, "created_at": baseTime, "updated_at": baseTime, - "owner_id": userID, "visibility": "private", + "owner_id": userID, }, { // minimal row: nullable optionals (git_remote, labels, owner...) left NULL "id": "11111111-1111-1111-1111-1111111111aa", "name": "Minimal Project", diff --git a/pkg/api/types.go b/pkg/api/types.go index e7a2e2a26a..565421b702 100644 --- a/pkg/api/types.go +++ b/pkg/api/types.go @@ -1006,7 +1006,7 @@ type StatusEvent struct { Timestamp string `json:"timestamp"` } -// Visibility constants for agent and project access control. +// Visibility constants for resource access control (skills, templates, harness configs). const ( VisibilityPrivate = "private" // Only the owner can access VisibilityTeam = "team" // Team members can access @@ -1029,9 +1029,8 @@ type ProjectInfo struct { Updated time.Time `json:"updated,omitempty"` // Last modification timestamp // Ownership - CreatedBy string `json:"createdBy,omitempty"` // User/system that created the project - OwnerID string `json:"ownerId,omitempty"` // Current owner user ID - Visibility string `json:"visibility,omitempty"` // Access level: private, team, public + CreatedBy string `json:"createdBy,omitempty"` // User/system that created the project + OwnerID string `json:"ownerId,omitempty"` // Current owner user ID // Metadata Labels map[string]string `json:"labels,omitempty"` diff --git a/pkg/ent/migrate/schema.go b/pkg/ent/migrate/schema.go index bdff91dbac..cc36b50f56 100644 --- a/pkg/ent/migrate/schema.go +++ b/pkg/ent/migrate/schema.go @@ -1301,7 +1301,6 @@ var ( {Name: "updated", Type: field.TypeTime}, {Name: "created_by", Type: field.TypeString, Nullable: true}, {Name: "owner_id", Type: field.TypeString, Nullable: true}, - {Name: "visibility", Type: field.TypeString, Default: "private"}, {Name: "github_installation_id", Type: field.TypeInt64, Nullable: true}, {Name: "github_permissions", Type: field.TypeString, Nullable: true}, {Name: "github_app_status", Type: field.TypeString, Nullable: true}, diff --git a/pkg/ent/mutation.go b/pkg/ent/mutation.go index 0e88400df0..6e4cd5a00c 100644 --- a/pkg/ent/mutation.go +++ b/pkg/ent/mutation.go @@ -37151,7 +37151,6 @@ type ProjectMutation struct { updated *time.Time created_by *string owner_id *string - visibility *string github_installation_id *int64 addgithub_installation_id *int64 github_permissions *string @@ -37757,42 +37756,6 @@ func (m *ProjectMutation) ResetOwnerID() { delete(m.clearedFields, project.FieldOwnerID) } -// SetVisibility sets the "visibility" field. -func (m *ProjectMutation) SetVisibility(s string) { - m.visibility = &s -} - -// Visibility returns the value of the "visibility" field in the mutation. -func (m *ProjectMutation) Visibility() (r string, exists bool) { - v := m.visibility - if v == nil { - return - } - return *v, true -} - -// OldVisibility returns the old "visibility" field's value of the Project entity. -// If the Project object wasn't provided to the builder, the object is fetched from the database. -// An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *ProjectMutation) OldVisibility(ctx context.Context) (v string, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldVisibility is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldVisibility requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldVisibility: %w", err) - } - return oldValue.Visibility, nil -} - -// ResetVisibility resets all changes to the "visibility" field. -func (m *ProjectMutation) ResetVisibility() { - m.visibility = nil -} - // SetGithubInstallationID sets the "github_installation_id" field. func (m *ProjectMutation) SetGithubInstallationID(i int64) { m.github_installation_id = &i @@ -38098,7 +38061,7 @@ func (m *ProjectMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ProjectMutation) Fields() []string { - fields := make([]string, 0, 16) + fields := make([]string, 0, 15) if m.name != nil { fields = append(fields, project.FieldName) } @@ -38132,9 +38095,6 @@ func (m *ProjectMutation) Fields() []string { if m.owner_id != nil { fields = append(fields, project.FieldOwnerID) } - if m.visibility != nil { - fields = append(fields, project.FieldVisibility) - } if m.github_installation_id != nil { fields = append(fields, project.FieldGithubInstallationID) } @@ -38177,8 +38137,6 @@ func (m *ProjectMutation) Field(name string) (ent.Value, bool) { return m.CreatedBy() case project.FieldOwnerID: return m.OwnerID() - case project.FieldVisibility: - return m.Visibility() case project.FieldGithubInstallationID: return m.GithubInstallationID() case project.FieldGithubPermissions: @@ -38218,8 +38176,6 @@ func (m *ProjectMutation) OldField(ctx context.Context, name string) (ent.Value, return m.OldCreatedBy(ctx) case project.FieldOwnerID: return m.OldOwnerID(ctx) - case project.FieldVisibility: - return m.OldVisibility(ctx) case project.FieldGithubInstallationID: return m.OldGithubInstallationID(ctx) case project.FieldGithubPermissions: @@ -38314,13 +38270,6 @@ func (m *ProjectMutation) SetField(name string, value ent.Value) error { } m.SetOwnerID(v) return nil - case project.FieldVisibility: - v, ok := value.(string) - if !ok { - return fmt.Errorf("unexpected type %T for field %s", value, name) - } - m.SetVisibility(v) - return nil case project.FieldGithubInstallationID: v, ok := value.(int64) if !ok { @@ -38515,9 +38464,6 @@ func (m *ProjectMutation) ResetField(name string) error { case project.FieldOwnerID: m.ResetOwnerID() return nil - case project.FieldVisibility: - m.ResetVisibility() - return nil case project.FieldGithubInstallationID: m.ResetGithubInstallationID() return nil diff --git a/pkg/ent/project.go b/pkg/ent/project.go index 9110915bcd..3a154b13df 100644 --- a/pkg/ent/project.go +++ b/pkg/ent/project.go @@ -41,8 +41,6 @@ type Project struct { CreatedBy string `json:"created_by,omitempty"` // OwnerID holds the value of the "owner_id" field. OwnerID string `json:"owner_id,omitempty"` - // Visibility holds the value of the "visibility" field. - Visibility string `json:"visibility,omitempty"` // GithubInstallationID holds the value of the "github_installation_id" field. GithubInstallationID *int64 `json:"github_installation_id,omitempty"` // GithubPermissions holds the value of the "github_permissions" field. @@ -84,7 +82,7 @@ func (*Project) scanValues(columns []string) ([]any, error) { values[i] = new([]byte) case project.FieldGithubInstallationID: values[i] = new(sql.NullInt64) - case project.FieldName, project.FieldSlug, project.FieldGitRemote, project.FieldDefaultRuntimeBrokerID, project.FieldSharedDirs, project.FieldCreatedBy, project.FieldOwnerID, project.FieldVisibility, project.FieldGithubPermissions, project.FieldGithubAppStatus, project.FieldGitIdentity: + case project.FieldName, project.FieldSlug, project.FieldGitRemote, project.FieldDefaultRuntimeBrokerID, project.FieldSharedDirs, project.FieldCreatedBy, project.FieldOwnerID, project.FieldGithubPermissions, project.FieldGithubAppStatus, project.FieldGitIdentity: values[i] = new(sql.NullString) case project.FieldCreated, project.FieldUpdated: values[i] = new(sql.NullTime) @@ -183,12 +181,6 @@ func (_m *Project) assignValues(columns []string, values []any) error { } else if value.Valid { _m.OwnerID = value.String } - case project.FieldVisibility: - if value, ok := values[i].(*sql.NullString); !ok { - return fmt.Errorf("unexpected type %T for field visibility", values[i]) - } else if value.Valid { - _m.Visibility = value.String - } case project.FieldGithubInstallationID: if value, ok := values[i].(*sql.NullInt64); !ok { return fmt.Errorf("unexpected type %T for field github_installation_id", values[i]) @@ -292,9 +284,6 @@ func (_m *Project) String() string { builder.WriteString("owner_id=") builder.WriteString(_m.OwnerID) builder.WriteString(", ") - builder.WriteString("visibility=") - builder.WriteString(_m.Visibility) - builder.WriteString(", ") if v := _m.GithubInstallationID; v != nil { builder.WriteString("github_installation_id=") builder.WriteString(fmt.Sprintf("%v", *v)) diff --git a/pkg/ent/project/project.go b/pkg/ent/project/project.go index a8988dcc56..737c94b8d3 100644 --- a/pkg/ent/project/project.go +++ b/pkg/ent/project/project.go @@ -37,8 +37,6 @@ const ( FieldCreatedBy = "created_by" // FieldOwnerID holds the string denoting the owner_id field in the database. FieldOwnerID = "owner_id" - // FieldVisibility holds the string denoting the visibility field in the database. - FieldVisibility = "visibility" // FieldGithubInstallationID holds the string denoting the github_installation_id field in the database. FieldGithubInstallationID = "github_installation_id" // FieldGithubPermissions holds the string denoting the github_permissions field in the database. @@ -74,7 +72,6 @@ var Columns = []string{ FieldUpdated, FieldCreatedBy, FieldOwnerID, - FieldVisibility, FieldGithubInstallationID, FieldGithubPermissions, FieldGithubAppStatus, @@ -102,8 +99,6 @@ var ( DefaultUpdated func() time.Time // UpdateDefaultUpdated holds the default value on update for the "updated" field. UpdateDefaultUpdated func() time.Time - // DefaultVisibility holds the default value on creation for the "visibility" field. - DefaultVisibility string // DefaultID holds the default value on creation for the "id" field. DefaultID func() uuid.UUID ) @@ -161,11 +156,6 @@ func ByOwnerID(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldOwnerID, opts...).ToFunc() } -// ByVisibility orders the results by the visibility field. -func ByVisibility(opts ...sql.OrderTermOption) OrderOption { - return sql.OrderByField(FieldVisibility, opts...).ToFunc() -} - // ByGithubInstallationID orders the results by the github_installation_id field. func ByGithubInstallationID(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldGithubInstallationID, opts...).ToFunc() diff --git a/pkg/ent/project/where.go b/pkg/ent/project/where.go index d7e0f6b94e..2aa1beec07 100644 --- a/pkg/ent/project/where.go +++ b/pkg/ent/project/where.go @@ -101,11 +101,6 @@ func OwnerID(v string) predicate.Project { return predicate.Project(sql.FieldEQ(FieldOwnerID, v)) } -// Visibility applies equality check predicate on the "visibility" field. It's identical to VisibilityEQ. -func Visibility(v string) predicate.Project { - return predicate.Project(sql.FieldEQ(FieldVisibility, v)) -} - // GithubInstallationID applies equality check predicate on the "github_installation_id" field. It's identical to GithubInstallationIDEQ. func GithubInstallationID(v int64) predicate.Project { return predicate.Project(sql.FieldEQ(FieldGithubInstallationID, v)) @@ -731,71 +726,6 @@ func OwnerIDContainsFold(v string) predicate.Project { return predicate.Project(sql.FieldContainsFold(FieldOwnerID, v)) } -// VisibilityEQ applies the EQ predicate on the "visibility" field. -func VisibilityEQ(v string) predicate.Project { - return predicate.Project(sql.FieldEQ(FieldVisibility, v)) -} - -// VisibilityNEQ applies the NEQ predicate on the "visibility" field. -func VisibilityNEQ(v string) predicate.Project { - return predicate.Project(sql.FieldNEQ(FieldVisibility, v)) -} - -// VisibilityIn applies the In predicate on the "visibility" field. -func VisibilityIn(vs ...string) predicate.Project { - return predicate.Project(sql.FieldIn(FieldVisibility, vs...)) -} - -// VisibilityNotIn applies the NotIn predicate on the "visibility" field. -func VisibilityNotIn(vs ...string) predicate.Project { - return predicate.Project(sql.FieldNotIn(FieldVisibility, vs...)) -} - -// VisibilityGT applies the GT predicate on the "visibility" field. -func VisibilityGT(v string) predicate.Project { - return predicate.Project(sql.FieldGT(FieldVisibility, v)) -} - -// VisibilityGTE applies the GTE predicate on the "visibility" field. -func VisibilityGTE(v string) predicate.Project { - return predicate.Project(sql.FieldGTE(FieldVisibility, v)) -} - -// VisibilityLT applies the LT predicate on the "visibility" field. -func VisibilityLT(v string) predicate.Project { - return predicate.Project(sql.FieldLT(FieldVisibility, v)) -} - -// VisibilityLTE applies the LTE predicate on the "visibility" field. -func VisibilityLTE(v string) predicate.Project { - return predicate.Project(sql.FieldLTE(FieldVisibility, v)) -} - -// VisibilityContains applies the Contains predicate on the "visibility" field. -func VisibilityContains(v string) predicate.Project { - return predicate.Project(sql.FieldContains(FieldVisibility, v)) -} - -// VisibilityHasPrefix applies the HasPrefix predicate on the "visibility" field. -func VisibilityHasPrefix(v string) predicate.Project { - return predicate.Project(sql.FieldHasPrefix(FieldVisibility, v)) -} - -// VisibilityHasSuffix applies the HasSuffix predicate on the "visibility" field. -func VisibilityHasSuffix(v string) predicate.Project { - return predicate.Project(sql.FieldHasSuffix(FieldVisibility, v)) -} - -// VisibilityEqualFold applies the EqualFold predicate on the "visibility" field. -func VisibilityEqualFold(v string) predicate.Project { - return predicate.Project(sql.FieldEqualFold(FieldVisibility, v)) -} - -// VisibilityContainsFold applies the ContainsFold predicate on the "visibility" field. -func VisibilityContainsFold(v string) predicate.Project { - return predicate.Project(sql.FieldContainsFold(FieldVisibility, v)) -} - // GithubInstallationIDEQ applies the EQ predicate on the "github_installation_id" field. func GithubInstallationIDEQ(v int64) predicate.Project { return predicate.Project(sql.FieldEQ(FieldGithubInstallationID, v)) diff --git a/pkg/ent/project_create.go b/pkg/ent/project_create.go index 376e38471b..26b36efa1b 100644 --- a/pkg/ent/project_create.go +++ b/pkg/ent/project_create.go @@ -147,20 +147,6 @@ func (_c *ProjectCreate) SetNillableOwnerID(v *string) *ProjectCreate { return _c } -// SetVisibility sets the "visibility" field. -func (_c *ProjectCreate) SetVisibility(v string) *ProjectCreate { - _c.mutation.SetVisibility(v) - return _c -} - -// SetNillableVisibility sets the "visibility" field if the given value is not nil. -func (_c *ProjectCreate) SetNillableVisibility(v *string) *ProjectCreate { - if v != nil { - _c.SetVisibility(*v) - } - return _c -} - // SetGithubInstallationID sets the "github_installation_id" field. func (_c *ProjectCreate) SetGithubInstallationID(v int64) *ProjectCreate { _c.mutation.SetGithubInstallationID(v) @@ -289,10 +275,6 @@ func (_c *ProjectCreate) defaults() { v := project.DefaultUpdated() _c.mutation.SetUpdated(v) } - if _, ok := _c.mutation.Visibility(); !ok { - v := project.DefaultVisibility - _c.mutation.SetVisibility(v) - } if _, ok := _c.mutation.ID(); !ok { v := project.DefaultID() _c.mutation.SetID(v) @@ -323,9 +305,6 @@ func (_c *ProjectCreate) check() error { if _, ok := _c.mutation.Updated(); !ok { return &ValidationError{Name: "updated", err: errors.New(`ent: missing required field "Project.updated"`)} } - if _, ok := _c.mutation.Visibility(); !ok { - return &ValidationError{Name: "visibility", err: errors.New(`ent: missing required field "Project.visibility"`)} - } return nil } @@ -406,10 +385,6 @@ func (_c *ProjectCreate) createSpec() (*Project, *sqlgraph.CreateSpec) { _spec.SetField(project.FieldOwnerID, field.TypeString, value) _node.OwnerID = value } - if value, ok := _c.mutation.Visibility(); ok { - _spec.SetField(project.FieldVisibility, field.TypeString, value) - _node.Visibility = value - } if value, ok := _c.mutation.GithubInstallationID(); ok { _spec.SetField(project.FieldGithubInstallationID, field.TypeInt64, value) _node.GithubInstallationID = &value @@ -656,18 +631,6 @@ func (u *ProjectUpsert) ClearOwnerID() *ProjectUpsert { return u } -// SetVisibility sets the "visibility" field. -func (u *ProjectUpsert) SetVisibility(v string) *ProjectUpsert { - u.Set(project.FieldVisibility, v) - return u -} - -// UpdateVisibility sets the "visibility" field to the value that was provided on create. -func (u *ProjectUpsert) UpdateVisibility() *ProjectUpsert { - u.SetExcluded(project.FieldVisibility) - return u -} - // SetGithubInstallationID sets the "github_installation_id" field. func (u *ProjectUpsert) SetGithubInstallationID(v int64) *ProjectUpsert { u.Set(project.FieldGithubInstallationID, v) @@ -986,20 +949,6 @@ func (u *ProjectUpsertOne) ClearOwnerID() *ProjectUpsertOne { }) } -// SetVisibility sets the "visibility" field. -func (u *ProjectUpsertOne) SetVisibility(v string) *ProjectUpsertOne { - return u.Update(func(s *ProjectUpsert) { - s.SetVisibility(v) - }) -} - -// UpdateVisibility sets the "visibility" field to the value that was provided on create. -func (u *ProjectUpsertOne) UpdateVisibility() *ProjectUpsertOne { - return u.Update(func(s *ProjectUpsert) { - s.UpdateVisibility() - }) -} - // SetGithubInstallationID sets the "github_installation_id" field. func (u *ProjectUpsertOne) SetGithubInstallationID(v int64) *ProjectUpsertOne { return u.Update(func(s *ProjectUpsert) { @@ -1498,20 +1447,6 @@ func (u *ProjectUpsertBulk) ClearOwnerID() *ProjectUpsertBulk { }) } -// SetVisibility sets the "visibility" field. -func (u *ProjectUpsertBulk) SetVisibility(v string) *ProjectUpsertBulk { - return u.Update(func(s *ProjectUpsert) { - s.SetVisibility(v) - }) -} - -// UpdateVisibility sets the "visibility" field to the value that was provided on create. -func (u *ProjectUpsertBulk) UpdateVisibility() *ProjectUpsertBulk { - return u.Update(func(s *ProjectUpsert) { - s.UpdateVisibility() - }) -} - // SetGithubInstallationID sets the "github_installation_id" field. func (u *ProjectUpsertBulk) SetGithubInstallationID(v int64) *ProjectUpsertBulk { return u.Update(func(s *ProjectUpsert) { diff --git a/pkg/ent/project_update.go b/pkg/ent/project_update.go index 81ada5256a..9714d1131f 100644 --- a/pkg/ent/project_update.go +++ b/pkg/ent/project_update.go @@ -188,20 +188,6 @@ func (_u *ProjectUpdate) ClearOwnerID() *ProjectUpdate { return _u } -// SetVisibility sets the "visibility" field. -func (_u *ProjectUpdate) SetVisibility(v string) *ProjectUpdate { - _u.mutation.SetVisibility(v) - return _u -} - -// SetNillableVisibility sets the "visibility" field if the given value is not nil. -func (_u *ProjectUpdate) SetNillableVisibility(v *string) *ProjectUpdate { - if v != nil { - _u.SetVisibility(*v) - } - return _u -} - // SetGithubInstallationID sets the "github_installation_id" field. func (_u *ProjectUpdate) SetGithubInstallationID(v int64) *ProjectUpdate { _u.mutation.ResetGithubInstallationID() @@ -444,9 +430,6 @@ func (_u *ProjectUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.OwnerIDCleared() { _spec.ClearField(project.FieldOwnerID, field.TypeString) } - if value, ok := _u.mutation.Visibility(); ok { - _spec.SetField(project.FieldVisibility, field.TypeString, value) - } if value, ok := _u.mutation.GithubInstallationID(); ok { _spec.SetField(project.FieldGithubInstallationID, field.TypeInt64, value) } @@ -697,20 +680,6 @@ func (_u *ProjectUpdateOne) ClearOwnerID() *ProjectUpdateOne { return _u } -// SetVisibility sets the "visibility" field. -func (_u *ProjectUpdateOne) SetVisibility(v string) *ProjectUpdateOne { - _u.mutation.SetVisibility(v) - return _u -} - -// SetNillableVisibility sets the "visibility" field if the given value is not nil. -func (_u *ProjectUpdateOne) SetNillableVisibility(v *string) *ProjectUpdateOne { - if v != nil { - _u.SetVisibility(*v) - } - return _u -} - // SetGithubInstallationID sets the "github_installation_id" field. func (_u *ProjectUpdateOne) SetGithubInstallationID(v int64) *ProjectUpdateOne { _u.mutation.ResetGithubInstallationID() @@ -983,9 +952,6 @@ func (_u *ProjectUpdateOne) sqlSave(ctx context.Context) (_node *Project, err er if _u.mutation.OwnerIDCleared() { _spec.ClearField(project.FieldOwnerID, field.TypeString) } - if value, ok := _u.mutation.Visibility(); ok { - _spec.SetField(project.FieldVisibility, field.TypeString, value) - } if value, ok := _u.mutation.GithubInstallationID(); ok { _spec.SetField(project.FieldGithubInstallationID, field.TypeInt64, value) } diff --git a/pkg/ent/runtime.go b/pkg/ent/runtime.go index a799c5683c..8d9e9c2a52 100644 --- a/pkg/ent/runtime.go +++ b/pkg/ent/runtime.go @@ -1160,10 +1160,6 @@ func init() { project.DefaultUpdated = projectDescUpdated.Default.(func() time.Time) // project.UpdateDefaultUpdated holds the default value on update for the updated field. project.UpdateDefaultUpdated = projectDescUpdated.UpdateDefault.(func() time.Time) - // projectDescVisibility is the schema descriptor for visibility field. - projectDescVisibility := projectFields[12].Descriptor() - // project.DefaultVisibility holds the default value on creation for the visibility field. - project.DefaultVisibility = projectDescVisibility.Default.(string) // projectDescID is the schema descriptor for id field. projectDescID := projectFields[0].Descriptor() // project.DefaultID holds the default value on creation for the id field. diff --git a/pkg/ent/schema/project.go b/pkg/ent/schema/project.go index b795ee85a7..3d346d7342 100644 --- a/pkg/ent/schema/project.go +++ b/pkg/ent/schema/project.go @@ -71,8 +71,6 @@ func (Project) Fields() []ent.Field { Optional(), field.String("owner_id"). Optional(), - field.String("visibility"). - Default("private"), field.Int64("github_installation_id"). Optional(). Nillable(), diff --git a/pkg/hub/attachments_agent_test.go b/pkg/hub/attachments_agent_test.go index 7f322ea8c7..a593a65112 100644 --- a/pkg/hub/attachments_agent_test.go +++ b/pkg/hub/attachments_agent_test.go @@ -140,7 +140,6 @@ func agentAttachmentServer(t *testing.T) (*Server, store.Store, *store.Project, ID: api.NewUUID(), Name: "attach-project", Slug: "attach-project", - Visibility: store.VisibilityPrivate, SharedDirs: []api.SharedDir{{Name: attachmentSharedDirName}}, } if err := s.CreateProject(context.Background(), project); err != nil { @@ -272,12 +271,11 @@ func TestOutboundMessage_AttachmentsLinkedToMessage(t *testing.T) { } agent := &store.Agent{ - ID: api.NewUUID(), - Name: "sender", - Slug: "sender", - ProjectID: project.ID, - Phase: "running", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "sender", + Slug: "sender", + ProjectID: project.ID, + Phase: "running", } if err := s.CreateAgent(ctx, agent); err != nil { t.Fatalf("CreateAgent: %v", err) diff --git a/pkg/hub/audit_authz_test.go b/pkg/hub/audit_authz_test.go index c54686b222..7c96655c73 100644 --- a/pkg/hub/audit_authz_test.go +++ b/pkg/hub/audit_authz_test.go @@ -53,12 +53,11 @@ func TestDecisionAudit_AllowAndDeny(t *testing.T) { // Create a project and an agent for testing ctx := context.Background() project := &store.Project{ - ID: tid("audit-project"), - Name: "Audit Test Project", - Slug: "audit-project", - Visibility: "private", - CreatedBy: DevUserID, - OwnerID: DevUserID, + ID: tid("audit-project"), + Name: "Audit Test Project", + Slug: "audit-project", + CreatedBy: DevUserID, + OwnerID: DevUserID, } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -245,12 +244,11 @@ func TestExplainAPI_SuperAdmin(t *testing.T) { // Create a project ctx := context.Background() project := &store.Project{ - ID: tid("explain-project"), - Name: "Explain Test", - Slug: "explain-test", - Visibility: "private", - CreatedBy: DevUserID, - OwnerID: DevUserID, + ID: tid("explain-project"), + Name: "Explain Test", + Slug: "explain-test", + CreatedBy: DevUserID, + OwnerID: DevUserID, } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -312,12 +310,11 @@ func TestExplainAPI_Self(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: tid("explain-self-project"), - Name: "Explain Self Test", - Slug: "explain-self", - Visibility: "private", - CreatedBy: DevUserID, - OwnerID: DevUserID, + ID: tid("explain-self-project"), + Name: "Explain Self Test", + Slug: "explain-self", + CreatedBy: DevUserID, + OwnerID: DevUserID, } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -433,7 +430,7 @@ func TestExplainAPI_SuperAdminCanExplainForOthersViaDecide(t *testing.T) { project := &store.Project{ ID: tid("explain-decide-project"), Name: "Explain Decide Test", Slug: "explain-decide", - Visibility: "private", CreatedBy: DevUserID, OwnerID: DevUserID, + CreatedBy: DevUserID, OwnerID: DevUserID, } require.NoError(t, s.CreateProject(ctx, project)) @@ -456,12 +453,11 @@ func TestExplainAPI_NoSecretLeakage(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: tid("explain-leak-project"), - Name: "Leak Test", - Slug: "leak-test", - Visibility: "private", - CreatedBy: DevUserID, - OwnerID: DevUserID, + ID: tid("explain-leak-project"), + Name: "Leak Test", + Slug: "leak-test", + CreatedBy: DevUserID, + OwnerID: DevUserID, } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -500,12 +496,11 @@ func TestExplainAPI_TraceContainsDecidingPolicy(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: tid("trace-policy-project"), - Name: "Trace Policy Test", - Slug: "trace-policy", - Visibility: "private", - CreatedBy: DevUserID, - OwnerID: DevUserID, + ID: tid("trace-policy-project"), + Name: "Trace Policy Test", + Slug: "trace-policy", + CreatedBy: DevUserID, + OwnerID: DevUserID, } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -610,12 +605,11 @@ func TestMutationAudit_CredentialRevocation(t *testing.T) { // Create a UAT for the dev user first project := &store.Project{ - ID: tid("revoke-project"), - Name: "Revoke Test", - Slug: "revoke-test", - Visibility: "private", - CreatedBy: DevUserID, - OwnerID: DevUserID, + ID: tid("revoke-project"), + Name: "Revoke Test", + Slug: "revoke-test", + CreatedBy: DevUserID, + OwnerID: DevUserID, } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) diff --git a/pkg/hub/authz_agent_baseline_test.go b/pkg/hub/authz_agent_baseline_test.go index 0873ff780e..fcba923bc8 100644 --- a/pkg/hub/authz_agent_baseline_test.go +++ b/pkg/hub/authz_agent_baseline_test.go @@ -624,8 +624,8 @@ func TestMatchesResource_HubAndResourceScopesUnchanged(t *testing.T) { // of their literals: the only configurations whose behaviour changes are // user-authored project-scoped policies targeting a parentless resource type. // -// - seed.go's hub-member-read-all and hub-member-create-projects are -// ScopeType "hub", so the `case "project"` arm never runs for them. +// - seed.go's per-type hub-member-read-* policies and hub-member-create-projects +// are ScopeType "hub", so the `case "project"` arm never runs for them. // - handlers_projects_core.go's project::member-create-agents is // project-scoped but ResourceType "agent"; agent resources always carry a // project parent, so it matches exactly the same set as before. @@ -656,7 +656,9 @@ func TestMatchesResource_SeededPoliciesUnaffected(t *testing.T) { parentlessTemplate := templateResource(&store.Template{ID: tid("seed-template")}) ownProject := projectResource(project) - for _, name := range []string{"hub-member-read-all", "hub-member-create-projects"} { + // After narrowing hub-member-read-all, per-type read policies exist for + // directory resources and hub-member-create-projects remains unchanged. + for _, name := range []string{"hub-member-read-user", "hub-member-read-group", "hub-member-read-template", "hub-member-read-harness_config", "hub-member-read-broker", "hub-member-read-runtime_broker", "hub-member-read-gcp_service_account", "hub-member-read-policy", "hub-member-read-skill", "hub-member-read-quota", "hub-member-read-role", "hub-member-read-role_binding", "hub-member-read-hub", "hub-member-create-projects"} { t.Run(name, func(t *testing.T) { p := byName(name) require.Equal(t, "hub", p.ScopeType, diff --git a/pkg/hub/bootstrap_test.go b/pkg/hub/bootstrap_test.go index b34ad0183d..e7fdfaf689 100644 --- a/pkg/hub/bootstrap_test.go +++ b/pkg/hub/bootstrap_test.go @@ -752,7 +752,6 @@ func TestSyncToFinalize_BootstrapMode(t *testing.T) { ProjectID: projectID, RuntimeBrokerID: tid("broker_bootstrap_test"), Phase: string(state.PhaseProvisioning), - Visibility: store.VisibilityPrivate, AppliedConfig: &store.AgentAppliedConfig{ Task: "test task", }, @@ -838,7 +837,6 @@ func TestSyncToFinalize_BootstrapMode_MissingFile(t *testing.T) { ProjectID: projectID, RuntimeBrokerID: tid("broker_bootstrap_test"), Phase: string(state.PhaseProvisioning), - Visibility: store.VisibilityPrivate, } if err := s.CreateAgent(ctx, agent); err != nil { t.Fatalf("failed to create agent: %v", err) @@ -882,7 +880,6 @@ func TestSyncToFinalize_RejectsStoppedAgent(t *testing.T) { ProjectID: projectID, RuntimeBrokerID: tid("broker_bootstrap_test"), Phase: string(state.PhaseStopped), - Visibility: store.VisibilityPrivate, } if err := s.CreateAgent(ctx, agent); err != nil { t.Fatalf("failed to create agent: %v", err) @@ -932,7 +929,6 @@ func TestSyncToFinalize_BootstrapMode_NoDispatcher(t *testing.T) { ProjectID: projectID, RuntimeBrokerID: tid("broker_bootstrap_test"), Phase: string(state.PhaseProvisioning), - Visibility: store.VisibilityPrivate, } if err := s.CreateAgent(ctx, agent); err != nil { t.Fatalf("failed to create agent: %v", err) diff --git a/pkg/hub/broker_authz_test.go b/pkg/hub/broker_authz_test.go index 9b00f4f3d4..0b0245248b 100644 --- a/pkg/hub/broker_authz_test.go +++ b/pkg/hub/broker_authz_test.go @@ -42,10 +42,9 @@ func setupBroadcastProject(t *testing.T, srv *Server, s store.Store) *store.Proj ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Slug: "authz-bcast", - Name: "authz-bcast", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Slug: "authz-bcast", + Name: "authz-bcast", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -242,10 +241,9 @@ func setupChatAgent(t *testing.T, srv *Server, s store.Store) (*store.Project, * ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Slug: "chat-authz", - Name: "chat-authz", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Slug: "chat-authz", + Name: "chat-authz", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -350,10 +348,9 @@ func TestSendAgentRouted_MentionSkippedWithoutAttach(t *testing.T) { seedRoleDefinitions(ctx, s) project := &store.Project{ - ID: api.NewUUID(), - Slug: "mention-authz", - Name: "mention-authz", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Slug: "mention-authz", + Name: "mention-authz", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) diff --git a/pkg/hub/chat_notifications_test.go b/pkg/hub/chat_notifications_test.go index 892d293b8c..7bf7aee3cd 100644 --- a/pkg/hub/chat_notifications_test.go +++ b/pkg/hub/chat_notifications_test.go @@ -253,14 +253,12 @@ func TestChatNotifier_DMReceived_ResolvesAgentSlugFromSenderID(t *testing.T) { // Name is preferred over Slug. project := &store.Project{ ID: api.NewUUID(), Name: "f2-notif", Slug: "f2-notif", - Visibility: store.VisibilityPrivate, } require.NoError(t, env.store.CreateProject(ctx, project)) agent := &store.Agent{ ID: api.NewUUID(), Name: "Helpful Bot", Slug: "helpful-bot", ProjectID: project.ID, Phase: "running", - Visibility: store.VisibilityPrivate, } require.NoError(t, env.store.CreateAgent(ctx, agent)) @@ -311,14 +309,12 @@ func TestChatNotifier_DMReceived_DoesNotClobberCallerLabel(t *testing.T) { project := &store.Project{ ID: api.NewUUID(), Name: "f6-noclobber", Slug: "f6-noclobber", - Visibility: store.VisibilityPrivate, } require.NoError(t, env.store.CreateProject(ctx, project)) agent := &store.Agent{ ID: api.NewUUID(), Name: "Agent Real Name", Slug: "agent-slug", ProjectID: project.ID, Phase: "running", - Visibility: store.VisibilityPrivate, } require.NoError(t, env.store.CreateAgent(ctx, agent)) @@ -561,7 +557,6 @@ func TestAgentMentions_DoNotCreateUserNotifications(t *testing.T) { agent := &store.Agent{ ID: api.NewUUID(), Slug: "scout", Name: "Scout", Template: "claude", ProjectID: proj.ID, - Visibility: store.VisibilityPrivate, } require.NoError(t, s.CreateAgent(ctx, agent)) diff --git a/pkg/hub/demo_policy_test.go b/pkg/hub/demo_policy_test.go index 3364930ac0..8aae8e3e68 100644 --- a/pkg/hub/demo_policy_test.go +++ b/pkg/hub/demo_policy_test.go @@ -417,12 +417,15 @@ func TestDemoPolicy_SeedGroupsAndPolicies(t *testing.T) { assert.Equal(t, "Hub Members", group.Name) assert.Equal(t, store.GroupTypeExplicit, group.GroupType) - // Verify seed policies exist - policies, err := s.ListPolicies(ctx, store.PolicyFilter{Name: "hub-member-read-all"}, store.ListOptions{Limit: 1}) - require.NoError(t, err) - assert.Equal(t, 1, policies.TotalCount, "hub-member-read-all policy should exist") + // Verify seed policies exist. The former hub-member-read-all wildcard policy + // has been narrowed into per-type policies for directory resources. + for _, rt := range []string{"user", "group", "template", "harness_config", "broker", "runtime_broker", "gcp_service_account", "policy", "skill", "quota", "role", "role_binding", "hub"} { + policies, err := s.ListPolicies(ctx, store.PolicyFilter{Name: "hub-member-read-" + rt}, store.ListOptions{Limit: 1}) + require.NoError(t, err) + assert.Equal(t, 1, policies.TotalCount, "hub-member-read-%s policy should exist", rt) + } - policies, err = s.ListPolicies(ctx, store.PolicyFilter{Name: "hub-member-create-projects"}, store.ListOptions{Limit: 1}) + policies, err := s.ListPolicies(ctx, store.PolicyFilter{Name: "hub-member-create-projects"}, store.ListOptions{Limit: 1}) require.NoError(t, err) assert.Equal(t, 1, policies.TotalCount, "hub-member-create-projects policy should exist") } diff --git a/pkg/hub/dispatch_exec_test.go b/pkg/hub/dispatch_exec_test.go index 32a0b54c67..08d75b324a 100644 --- a/pkg/hub/dispatch_exec_test.go +++ b/pkg/hub/dispatch_exec_test.go @@ -129,11 +129,10 @@ func seedAgentWithBrokerID(t *testing.T, cs store.Store, brokerID string) *store t.Helper() ctx := context.Background() proj := &store.Project{ - ID: uuid.NewString(), - Name: "test-proj", - Slug: "tp-" + uuid.NewString()[:8], - Visibility: store.VisibilityPrivate, - OwnerID: uuid.NewString(), + ID: uuid.NewString(), + Name: "test-proj", + Slug: "tp-" + uuid.NewString()[:8], + OwnerID: uuid.NewString(), } require.NoError(t, cs.CreateProject(ctx, proj)) broker := &store.RuntimeBroker{ diff --git a/pkg/hub/dm_injection_security_test.go b/pkg/hub/dm_injection_security_test.go index f52d450b9e..6a8b34547c 100644 --- a/pkg/hub/dm_injection_security_test.go +++ b/pkg/hub/dm_injection_security_test.go @@ -84,41 +84,37 @@ func TestDMKeyIngress_UnauthorizedAgentCanInjectIntoForeignDM(t *testing.T) { // Project P1 — the attacker's project. p1 := &store.Project{ - ID: api.NewUUID(), - Name: "attacker-project", - Slug: "attacker-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "attacker-project", + Slug: "attacker-project", } require.NoError(t, s.CreateProject(ctx, p1)) // Project P2 — the victim's project (agent B lives here). p2 := &store.Project{ - ID: api.NewUUID(), - Name: "victim-project", - Slug: "victim-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "victim-project", + Slug: "victim-project", } require.NoError(t, s.CreateProject(ctx, p2)) // Agent A — attacker, lives in P1. agentA := &store.Agent{ - ID: api.NewUUID(), - Name: "attacker-agent", - Slug: "attacker-agent", - ProjectID: p1.ID, - Phase: "running", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "attacker-agent", + Slug: "attacker-agent", + ProjectID: p1.ID, + Phase: "running", } require.NoError(t, s.CreateAgent(ctx, agentA)) // Agent B — legitimate, lives in P2. agentB := &store.Agent{ - ID: api.NewUUID(), - Name: "legit-agent", - Slug: "legit-agent", - ProjectID: p2.ID, - Phase: "running", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "legit-agent", + Slug: "legit-agent", + ProjectID: p2.ID, + Phase: "running", } require.NoError(t, s.CreateAgent(ctx, agentB)) diff --git a/pkg/hub/events_integration_test.go b/pkg/hub/events_integration_test.go index 5f99cb4fbe..6036ec8645 100644 --- a/pkg/hub/events_integration_test.go +++ b/pkg/hub/events_integration_test.go @@ -78,10 +78,9 @@ func setupEventTestServer(t *testing.T) (*Server, store.Store, *ChannelEventPubl t.Cleanup(pub.Close) project := &store.Project{ - ID: tid("project-evt"), - Name: "Event Test Project", - Slug: "event-test-project", - Visibility: store.VisibilityPrivate, + ID: tid("project-evt"), + Name: "Event Test Project", + Slug: "event-test-project", } require.NoError(t, s.CreateProject(ctx, project)) diff --git a/pkg/hub/events_test.go b/pkg/hub/events_test.go index cdc9c538e8..23fe942ca7 100644 --- a/pkg/hub/events_test.go +++ b/pkg/hub/events_test.go @@ -166,7 +166,6 @@ func TestChannelEventPublisher_PublishAgentCreated(t *testing.T) { Runtime: "docker", RuntimeBrokerID: "b1", CreatedBy: "user1", - Visibility: "private", } pub.PublishAgentCreated(context.Background(), agent) diff --git a/pkg/hub/field_race_test.go b/pkg/hub/field_race_test.go index 9a41b9dd9c..3005b618ac 100644 --- a/pkg/hub/field_race_test.go +++ b/pkg/hub/field_race_test.go @@ -74,10 +74,9 @@ func TestChannelRegistryRace(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "cr-race", - Slug: "cr-race", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "cr-race", + Slug: "cr-race", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -202,10 +201,9 @@ func TestDispatcherRace(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "disp-race", - Slug: "disp-race", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "disp-race", + Slug: "disp-race", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) diff --git a/pkg/hub/handlers_agent_create_helpers_test.go b/pkg/hub/handlers_agent_create_helpers_test.go index 87bd67ff3f..9718972883 100644 --- a/pkg/hub/handlers_agent_create_helpers_test.go +++ b/pkg/hub/handlers_agent_create_helpers_test.go @@ -542,11 +542,10 @@ func setupPreStartHookStampingTest(t *testing.T) (*Server, store.Store, *store.P srv, s := testServer(t) project := &store.Project{ - ID: tid("psh-stamp-" + t.Name()), - Name: "PSH Stamping Project", - Slug: "psh-stamp-" + strings.ToLower(t.Name()), - Visibility: "private", - OwnerID: "dev@localhost", + ID: tid("psh-stamp-" + t.Name()), + Name: "PSH Stamping Project", + Slug: "psh-stamp-" + strings.ToLower(t.Name()), + OwnerID: "dev@localhost", } require.NoError(t, s.CreateProject(t.Context(), project)) return srv, s, project diff --git a/pkg/hub/handlers_agent_messaging_test.go b/pkg/hub/handlers_agent_messaging_test.go index b4ba603006..dacefbbcb9 100644 --- a/pkg/hub/handlers_agent_messaging_test.go +++ b/pkg/hub/handlers_agent_messaging_test.go @@ -73,10 +73,9 @@ func TestOutboundMessage_RateLimitsFloodingAgent(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "flood-project", - Slug: "flood-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "flood-project", + Slug: "flood-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -156,10 +155,9 @@ func TestOutboundMessage_UnknownTypeIsChargedAsAgentTraffic(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "type-project", - Slug: "type-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "type-project", + Slug: "type-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -208,10 +206,9 @@ func TestOutboundMessage_TranscriptMirrorDoesNotStarveAgentMessages(t *testing.T ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "mirror-project", - Slug: "mirror-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "mirror-project", + Slug: "mirror-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -279,10 +276,9 @@ func TestAgentMessage_B5_SpoofedSenderDoesNotDeriveConversationKey(t *testing.T) ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "b5-security-project", - Slug: "b5-security-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "b5-security-project", + Slug: "b5-security-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -409,7 +405,6 @@ func TestBroadcast_B5F1_SpoofedSenderDoesNotDeriveConversationKey(t *testing.T) project := &store.Project{ ID: api.NewUUID(), Name: "b5-f1-broadcast", Slug: "b5-f1-broadcast", - Visibility: store.VisibilityPrivate, } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -536,7 +531,6 @@ func TestBroadcast_B5F1b_BroadcastedForcedTrueServerSide(t *testing.T) { project := &store.Project{ ID: api.NewUUID(), Name: "b5-bcast-flag", Slug: "b5-bcast-flag", - Visibility: store.VisibilityPrivate, } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -633,7 +627,6 @@ func TestBroadcast_R1_BroadcastingAgentDoesNotReceiveOwnMessage(t *testing.T) { project := &store.Project{ ID: api.NewUUID(), Name: "r1-selfskip", Slug: "r1-selfskip", - Visibility: store.VisibilityPrivate, } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -739,7 +732,6 @@ func TestBroadcast_B5F1a_SenderOverrideStoresAuthIdentity(t *testing.T) { project := &store.Project{ ID: api.NewUUID(), Name: "b5-f1a", Slug: "b5-f1a", - Visibility: store.VisibilityPrivate, } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -844,7 +836,6 @@ func TestBroadcast_B5F1c_SelfSkipUsesAuthNotSender(t *testing.T) { project := &store.Project{ ID: api.NewUUID(), Name: "b5-f1c", Slug: "b5-f1c", - Visibility: store.VisibilityPrivate, } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -932,7 +923,6 @@ func TestBroker_R2_FanOutGlobalSelfSkipBySenderID(t *testing.T) { projectA := &store.Project{ ID: api.NewUUID(), Name: "r2-pa", Slug: "r2-pa", - Visibility: store.VisibilityPrivate, } if err := s.CreateProject(ctx, projectA); err != nil { t.Fatalf("CreateProject: %v", err) @@ -1018,7 +1008,6 @@ func TestBroker_R3b_WarnOnEmptySenderID(t *testing.T) { project := &store.Project{ ID: api.NewUUID(), Name: "r3b", Slug: "r3b", - Visibility: store.VisibilityPrivate, } require.NoError(t, s.CreateProject(ctx, project)) @@ -1105,10 +1094,9 @@ func def11Setup(t *testing.T) (srv *Server, s store.Store, projectID, agentSlug, agentSlug = "def11-agent" if err := s.CreateProject(ctx, &store.Project{ - ID: projectID, - Name: "def11-project", - Slug: "def11-project", - Visibility: store.VisibilityPrivate, + ID: projectID, + Name: "def11-project", + Slug: "def11-project", }); err != nil { t.Fatalf("CreateProject: %v", err) } @@ -1331,10 +1319,9 @@ func TestDEF19_GroupRecipient_FullHandlerPath(t *testing.T) { anchorSlug := agentSlugA if err := s.CreateProject(ctx, &store.Project{ - ID: projectID, - Name: "def19-project", - Slug: "def19-project", - Visibility: store.VisibilityPrivate, + ID: projectID, + Name: "def19-project", + Slug: "def19-project", }); err != nil { t.Fatalf("CreateProject: %v", err) } @@ -1519,10 +1506,9 @@ func def49Setup(t *testing.T) (srv *Server, s store.Store, projectID string, tar projectID = tid("def49-project") if err := s.CreateProject(ctx, &store.Project{ - ID: projectID, - Name: "def49-project", - Slug: "def49-project", - Visibility: store.VisibilityPrivate, + ID: projectID, + Name: "def49-project", + Slug: "def49-project", }); err != nil { t.Fatalf("CreateProject: %v", err) } @@ -1678,10 +1664,9 @@ func TestDEF49_CrossProject_GroupConversation(t *testing.T) { // Create a second project that the target agent does NOT belong to. otherProjectID := tid("def49-other-project") if err := s.CreateProject(ctx, &store.Project{ - ID: otherProjectID, - Name: "def49-other-project", - Slug: "def49-other-project", - Visibility: store.VisibilityPrivate, + ID: otherProjectID, + Name: "def49-other-project", + Slug: "def49-other-project", }); err != nil { t.Fatalf("CreateProject (other): %v", err) } diff --git a/pkg/hub/handlers_github_app_test.go b/pkg/hub/handlers_github_app_test.go index fe76df562f..eba35bfc4f 100644 --- a/pkg/hub/handlers_github_app_test.go +++ b/pkg/hub/handlers_github_app_test.go @@ -188,13 +188,12 @@ func TestHandleProjectGitHubInstallation(t *testing.T) { // Create a project project := &store.Project{ - ID: tid("project_gh_test"), - Slug: "gh-test-project", - Name: "GH Test Project", - GitRemote: "https://github.com/acme/widgets", - Created: time.Now(), - Updated: time.Now(), - Visibility: "private", + ID: tid("project_gh_test"), + Slug: "gh-test-project", + Name: "GH Test Project", + GitRemote: "https://github.com/acme/widgets", + Created: time.Now(), + Updated: time.Now(), } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -261,8 +260,7 @@ func TestHandleProjectGitHubStatus_PostNoInstallation(t *testing.T) { project := &store.Project{ ID: tid("project_gh_status_check"), Slug: "gh-status-check", Name: "GH Status Check", GitRemote: "https://github.com/acme/widgets", - Created: time.Now(), Updated: time.Now(), Visibility: "private", - } + Created: time.Now(), Updated: time.Now()} if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) } @@ -294,8 +292,7 @@ func TestHandleProjectGitHubStatus_PostWithInstallation(t *testing.T) { project := &store.Project{ ID: tid("project_gh_status_check2"), Slug: "gh-status-check2", Name: "GH Status Check 2", GitRemote: "https://github.com/acme/widgets", - Created: time.Now(), Updated: time.Now(), Visibility: "private", - } + Created: time.Now(), Updated: time.Now()} project.GitHubInstallationID = &instID project.GitHubAppStatus = &store.GitHubAppProjectStatus{ State: store.GitHubAppStateUnchecked, @@ -339,8 +336,7 @@ func TestHandleProjectGitHubInstallation_NotFoundInstallation(t *testing.T) { project := &store.Project{ ID: tid("project_gh_notfound"), Slug: "gh-nf", Name: "GH NF", - Created: time.Now(), Updated: time.Now(), Visibility: "private", - } + Created: time.Now(), Updated: time.Now()} if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) } @@ -363,8 +359,7 @@ func TestHandleProjectGitHubPermissions(t *testing.T) { project := &store.Project{ ID: tid("project_gh_perms"), Slug: "gh-perms", Name: "GH Perms", - Created: time.Now(), Updated: time.Now(), Visibility: "private", - } + Created: time.Now(), Updated: time.Now()} if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) } diff --git a/pkg/hub/handlers_message_delivery_test.go b/pkg/hub/handlers_message_delivery_test.go index e69f80662b..1837159449 100644 --- a/pkg/hub/handlers_message_delivery_test.go +++ b/pkg/hub/handlers_message_delivery_test.go @@ -49,10 +49,9 @@ func setupMessageTestAgent(t *testing.T, s store.Store, phase string) (projectID } project := &store.Project{ - ID: tid("msg-project"), - Slug: "msg-project", - Name: "msg-project", - Visibility: store.VisibilityPrivate, + ID: tid("msg-project"), + Slug: "msg-project", + Name: "msg-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -118,7 +117,6 @@ func TestHandleAgentMessage_StoppedReturns409(t *testing.T) { _ = s.CreateRuntimeBroker(ctx, broker) project := &store.Project{ ID: tid("msg-project-stop"), Slug: "msg-project-stop", Name: "msg-project-stop", - Visibility: store.VisibilityPrivate, } _ = s.CreateProject(ctx, project) agent := &store.Agent{ @@ -162,7 +160,6 @@ func TestHandleAgentMessage_ErrorReturns409(t *testing.T) { _ = s.CreateRuntimeBroker(ctx, broker) project := &store.Project{ ID: tid("msg-project-err"), Slug: "msg-project-err", Name: "msg-project-err", - Visibility: store.VisibilityPrivate, } _ = s.CreateProject(ctx, project) agent := &store.Agent{ @@ -206,7 +203,6 @@ func TestHandleAgentMessage_ProvisioningReturns409(t *testing.T) { _ = s.CreateRuntimeBroker(ctx, broker) project := &store.Project{ ID: tid("msg-project-prov"), Slug: "msg-project-prov", Name: "msg-project-prov", - Visibility: store.VisibilityPrivate, } _ = s.CreateProject(ctx, project) agent := &store.Agent{ @@ -287,7 +283,6 @@ func TestHandleProjectBroadcast_Returns202WithTargeting(t *testing.T) { _ = s.CreateRuntimeBroker(ctx, broker) project := &store.Project{ ID: tid("bcast-project"), Slug: "bcast-project", Name: "bcast-project", - Visibility: store.VisibilityPrivate, } _ = s.CreateProject(ctx, project) @@ -364,7 +359,6 @@ func TestHandleProjectBroadcast_AllRunning(t *testing.T) { _ = s.CreateRuntimeBroker(ctx, broker) project := &store.Project{ ID: tid("bcast-project-all"), Slug: "bcast-project-all", Name: "bcast-project-all", - Visibility: store.VisibilityPrivate, } _ = s.CreateProject(ctx, project) @@ -412,7 +406,6 @@ func TestHandleProjectBroadcast_NoAgents(t *testing.T) { ctx := context.Background() project := &store.Project{ ID: tid("bcast-project-empty"), Slug: "bcast-project-empty", Name: "bcast-project-empty", - Visibility: store.VisibilityPrivate, } _ = s.CreateProject(ctx, project) diff --git a/pkg/hub/handlers_messages_test.go b/pkg/hub/handlers_messages_test.go index 2904fa2fc9..aa679daccd 100644 --- a/pkg/hub/handlers_messages_test.go +++ b/pkg/hub/handlers_messages_test.go @@ -111,8 +111,7 @@ func setupMessagePrivacyTest(t *testing.T) ( agentID = tid("agent-msg-priv") require.NoError(t, s.CreateAgent(ctx, &store.Agent{ ID: agentID, Slug: "agent-msg-priv", Name: "Privacy Agent", - ProjectID: project.ID, OwnerID: alice.ID, Visibility: store.VisibilityPrivate, - Created: time.Now(), Updated: time.Now(), + ProjectID: project.ID, OwnerID: alice.ID, Created: time.Now(), Updated: time.Now(), })) // --- give bob read-only access on agents --- diff --git a/pkg/hub/handlers_permissions_test.go b/pkg/hub/handlers_permissions_test.go index d18c7facb5..657ef9b459 100644 --- a/pkg/hub/handlers_permissions_test.go +++ b/pkg/hub/handlers_permissions_test.go @@ -51,8 +51,7 @@ func permSeedAgent(t *testing.T, ctx context.Context, s store.Store, id string) _ = s.CreateProject(ctx, &store.Project{ID: projectID, Name: "Perm Agent Project", Slug: "perm-agent-project"}) err := s.CreateAgent(ctx, &store.Agent{ ID: id, Name: "Seed Agent", Slug: "seed-agent-" + id[:8], - ProjectID: projectID, Phase: "stopped", Visibility: store.VisibilityPrivate, - }) + ProjectID: projectID, Phase: "stopped"}) if err != nil && !errors.Is(err, store.ErrAlreadyExists) { t.Fatalf("seed agent %s: %v", id, err) } @@ -1279,9 +1278,9 @@ func TestPolicyList(t *testing.T) { t.Fatalf("failed to decode response: %v", err) } - // 3 test policies + 2 seeded policies (hub-member-read-all, hub-member-create-projects) = 5 - if len(resp.Policies) != 5 { - t.Errorf("expected 5 policies (3 test + 2 seeded), got %d", len(resp.Policies)) + // 3 test policies + 14 seeded policies (13 per-type read + 1 create-projects) = 17 + if len(resp.Policies) != 17 { + t.Errorf("expected 17 policies (3 test + 14 seeded), got %d", len(resp.Policies)) } } diff --git a/pkg/hub/handlers_projects_core.go b/pkg/hub/handlers_projects_core.go index 981a07f605..00788ab06d 100644 --- a/pkg/hub/handlers_projects_core.go +++ b/pkg/hub/handlers_projects_core.go @@ -52,7 +52,6 @@ type CreateProjectRequest struct { Name string `json:"name"` GitRemote string `json:"gitRemote,omitempty"` WorkspaceMode string `json:"workspaceMode,omitempty"` // "shared", "worktree-per-agent", or "per-agent" (default); only meaningful when gitRemote is set - Visibility string `json:"visibility,omitempty"` Labels map[string]string `json:"labels,omitempty"` GitHubToken string `json:"githubToken,omitempty"` } @@ -138,12 +137,11 @@ func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() filter := store.ProjectFilter{ - OwnerID: query.Get("ownerId"), - Visibility: query.Get("visibility"), - GitRemote: util.NormalizeGitRemote(query.Get("gitRemote")), - BrokerID: query.Get("brokerId"), - Name: query.Get("name"), - Slug: query.Get("slug"), + OwnerID: query.Get("ownerId"), + GitRemote: util.NormalizeGitRemote(query.Get("gitRemote")), + BrokerID: query.Get("brokerId"), + Name: query.Get("name"), + Slug: query.Get("slug"), } // Template filtering: default to excluding template projects. @@ -368,16 +366,11 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { } project := &store.Project{ - ID: projectID, - Name: displayName, - Slug: slug, - GitRemote: normalizedRemote, - Labels: req.Labels, - Visibility: req.Visibility, - } - - if project.Visibility == "" { - project.Visibility = store.VisibilityPrivate + ID: projectID, + Name: displayName, + Slug: slug, + GitRemote: normalizedRemote, + Labels: req.Labels, } // Set ownership from authenticated user @@ -896,12 +889,33 @@ func (s *Server) createProjectMembersGroupAndPolicy(ctx context.Context, project "project_id", project.ID, "policy", policyName, "error", err.Error()) } + // Create project-level read policy for members. This grants read+list on + // project and agent resources, making the project visible to its members. + // Without this, projects are invisible after the hub-member-read-all + // wildcard was narrowed to exclude project/agent/broker. + s.ensureProjectMemberReadPolicy(ctx, project, membersGroup.ID) + // Create the project-level service-account assign policy alongside it. // See projectAssignPolicyName in seed.go for why it is project-scoped and // what reach it preserves. ensureProjectAssignPolicy(ctx, s.store, project, membersGroup.ID) } +// ensureProjectMemberReadPolicy creates or ensures a project-scoped read+list +// policy for both "project" and "agent" resource types, bound to the project's +// members group. This is the mechanism that makes a project visible to its +// members after the global hub-member-read-all wildcard was narrowed to exclude +// project/agent/broker resources. +// +// "Public" / "everyone" visibility is achieved by adding the hub-members group +// to the project's members group — the read policy then applies transitively +// to all hub users. +func (s *Server) ensureProjectMemberReadPolicy(ctx context.Context, project *store.Project, membersGroupID string) { + // Delegate to the standalone function so the same logic is reusable + // from both inline handler paths and the startup backfill. + ensureProjectMemberReadPolicies(ctx, s.store, project, membersGroupID) +} + // hubManagedProjectPath returns the filesystem path for a hub-managed project workspace. // It prefers projects/ and falls back to groves/ for backward compatibility // with workspaces created before the grove-to-project rename. @@ -1380,12 +1394,11 @@ func (s *Server) handleProjectRegister(w http.ResponseWriter, r *http.Request) { } project = &store.Project{ - ID: projectID, - Name: displayName, - Slug: slug, - GitRemote: normalizedRemote, - Labels: req.Labels, - Visibility: store.VisibilityPrivate, + ID: projectID, + Name: displayName, + Slug: slug, + GitRemote: normalizedRemote, + Labels: req.Labels, } // Set ownership from authenticated user @@ -2528,6 +2541,17 @@ func (s *Server) getProject(w http.ResponseWriter, r *http.Request, id string) { return } + // SECURITY-GATE: CheckAccess — verify read access to individual project + identity := GetIdentityFromContext(ctx) + if identity == nil { + NotFound(w, "Project") + return + } + if decision := s.authzService.CheckAccess(ctx, identity, projectResource(project), ActionRead); !decision.Allowed { + NotFound(w, "Project") + return + } + // Ensure associated groups exist (backfill for projects created before // group support was added). These calls are idempotent. s.createProjectGroup(ctx, project) @@ -2569,7 +2593,6 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request, id string Name string `json:"name,omitempty"` Slug string `json:"slug,omitempty"` Labels map[string]string `json:"labels,omitempty"` - Visibility string `json:"visibility,omitempty"` DefaultRuntimeBrokerID string `json:"defaultRuntimeBrokerId,omitempty"` } @@ -2606,9 +2629,6 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request, id string if updates.Labels != nil { project.Labels = updates.Labels } - if updates.Visibility != "" { - project.Visibility = updates.Visibility - } if updates.DefaultRuntimeBrokerID != "" { project.DefaultRuntimeBrokerID = updates.DefaultRuntimeBrokerID } @@ -2678,6 +2698,23 @@ func (s *Server) migrateProjectSlug(ctx context.Context, project *store.Project, "project_id", project.ID, "old_policy", oldPolicyName, "error", err) } + // Migrate the project member-read-project policy name. + for _, suffix := range []string{"member-read-project", "member-read-agent"} { + oldReadPolicyName := "project:" + oldSlug + ":" + suffix + newReadPolicyName := "project:" + newSlug + ":" + suffix + if policies, err := s.store.ListPolicies(ctx, store.PolicyFilter{Name: oldReadPolicyName}, store.ListOptions{Limit: 1}); err == nil && len(policies.Items) > 0 { + policy := &policies.Items[0] + policy.Name = newReadPolicyName + if err := s.store.UpdatePolicy(ctx, policy); err != nil { + s.projectsLogger().Warn("failed to migrate project member read policy name", + "project_id", project.ID, "old_policy", oldReadPolicyName, "new_policy", newReadPolicyName, "error", err) + } + } else if err != nil { + s.projectsLogger().Warn("failed to retrieve project member read policy for migration", + "project_id", project.ID, "old_policy", oldReadPolicyName, "error", err) + } + } + // Migrate hub-managed project filesystem paths (best-effort). // Derive newPath from oldPath's parent to preserve the directory type (groves/ vs projects/). if oldPath, err := s.hubManagedProjectPath(oldSlug); err == nil { diff --git a/pkg/hub/handlers_test.go b/pkg/hub/handlers_test.go index b9a899fcfe..898c8cd533 100644 --- a/pkg/hub/handlers_test.go +++ b/pkg/hub/handlers_test.go @@ -1838,12 +1838,11 @@ func TestRuntimeBrokerListWithProjectLocalPath(t *testing.T) { // Create a project project := &store.Project{ - ID: tid("project_localpath_test"), - Name: "Local Path Test Project", - Slug: "local-path-test", - Visibility: store.VisibilityPrivate, - Created: time.Now(), - Updated: time.Now(), + ID: tid("project_localpath_test"), + Name: "Local Path Test Project", + Slug: "local-path-test", + Created: time.Now(), + Updated: time.Now(), } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -2985,10 +2984,9 @@ func TestOutboundMessage_UnknownRecipient(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "msg-project", - Slug: "msg-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "msg-project", + Slug: "msg-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatal(err) diff --git a/pkg/hub/handlers_user_templates_test.go b/pkg/hub/handlers_user_templates_test.go index 97fb9d5423..a57d7f14b2 100644 --- a/pkg/hub/handlers_user_templates_test.go +++ b/pkg/hub/handlers_user_templates_test.go @@ -68,16 +68,15 @@ func setupUserTemplateTest(t *testing.T) (*Server, store.Store, *store.User, *st func createUserTemplate(t *testing.T, s store.Store, ownerID, name string) *store.Template { t.Helper() tmpl := &store.Template{ - ID: api.NewUUID(), - Name: name, - Slug: api.Slugify(name), - Harness: "antigravity", - Scope: store.TemplateScopeUser, - ScopeID: ownerID, - OwnerID: ownerID, - CreatedBy: ownerID, - Status: store.TemplateStatusActive, - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: name, + Slug: api.Slugify(name), + Harness: "antigravity", + Scope: store.TemplateScopeUser, + ScopeID: ownerID, + OwnerID: ownerID, + CreatedBy: ownerID, + Status: store.TemplateStatusActive, } require.NoError(t, s.CreateTemplate(context.Background(), tmpl)) return tmpl @@ -164,7 +163,6 @@ func TestUpdateUserTemplate_NotFoundForOtherUser(t *testing.T) { Name: "updated-name", Description: "updated description", Status: store.TemplateStatusActive, - Visibility: store.VisibilityPrivate, } // Alice can update her own template. diff --git a/pkg/hub/heartbeat_timeout_test.go b/pkg/hub/heartbeat_timeout_test.go index 7a5765e8d7..83c720b785 100644 --- a/pkg/hub/heartbeat_timeout_test.go +++ b/pkg/hub/heartbeat_timeout_test.go @@ -84,10 +84,9 @@ func TestAgentHeartbeatTimeoutHandler_MarksStaleAgents(t *testing.T) { // Create project project := &store.Project{ - ID: api.NewUUID(), - Name: "Test Project", - Slug: "test-project-hb", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "Test Project", + Slug: "test-project-hb", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -174,10 +173,9 @@ func TestAgentHeartbeatTimeoutHandler_ClearedBySubsequentHeartbeat(t *testing.T) ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "Recovery Project", - Slug: "recovery-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "Recovery Project", + Slug: "recovery-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) diff --git a/pkg/hub/lifecycle_hook_evaluator_test.go b/pkg/hub/lifecycle_hook_evaluator_test.go index 7a5cf135d3..a207fc1424 100644 --- a/pkg/hub/lifecycle_hook_evaluator_test.go +++ b/pkg/hub/lifecycle_hook_evaluator_test.go @@ -153,12 +153,11 @@ func testEvaluatorStore(t *testing.T) store.Store { func seedHookProject(t *testing.T, s store.Store, name string) string { t.Helper() p := &store.Project{ - ID: uuid.New().String(), - Name: name, - Slug: name, - Visibility: "private", - Created: time.Now(), - Updated: time.Now(), + ID: uuid.New().String(), + Name: name, + Slug: name, + Created: time.Now(), + Updated: time.Now(), } require.NoError(t, s.CreateProject(context.Background(), p)) return p.ID diff --git a/pkg/hub/lifecycle_hook_executor_test.go b/pkg/hub/lifecycle_hook_executor_test.go index 28250cfd70..8672a11646 100644 --- a/pkg/hub/lifecycle_hook_executor_test.go +++ b/pkg/hub/lifecycle_hook_executor_test.go @@ -122,12 +122,11 @@ func seedExecutorProject(t *testing.T, s store.Store, name string) string { t.Helper() id := uuid.New().String() require.NoError(t, s.CreateProject(context.Background(), &store.Project{ - ID: id, - Name: name, - Slug: name, - Visibility: "private", - Created: time.Now(), - Updated: time.Now(), + ID: id, + Name: name, + Slug: name, + Created: time.Now(), + Updated: time.Now(), })) return id } @@ -733,12 +732,11 @@ func TestLifecycleHookExecutor_RenderVarsCorrectTrustClasses(t *testing.T) { // Use distinct Name and Slug to verify the code reads the correct field. projID := uuid.New().String() require.NoError(t, s.CreateProject(context.Background(), &store.Project{ - ID: projID, - Name: "Test Project Display Name", - Slug: "test-project-slug", - Visibility: "private", - Created: time.Now(), - Updated: time.Now(), + ID: projID, + Name: "Test Project Display Name", + Slug: "test-project-slug", + Created: time.Now(), + Updated: time.Now(), })) executor := newTestExecutor(s, nil, nil, slog.Default()) diff --git a/pkg/hub/lifecycle_hook_integration_test.go b/pkg/hub/lifecycle_hook_integration_test.go index a4a378fab8..54276113ca 100644 --- a/pkg/hub/lifecycle_hook_integration_test.go +++ b/pkg/hub/lifecycle_hook_integration_test.go @@ -138,12 +138,11 @@ func TestLifecycleHookIntegration_RegisterDeregisterFlow(t *testing.T) { // --- Seed project, SA, and agent --- projectID := uuid.New().String() require.NoError(t, s.CreateProject(ctx, &store.Project{ - ID: projectID, - Name: "integration-project", - Slug: "integration-project", - Visibility: "private", - Created: time.Now(), - Updated: time.Now(), + ID: projectID, + Name: "integration-project", + Slug: "integration-project", + Created: time.Now(), + Updated: time.Now(), })) saID := uuid.New().String() @@ -330,12 +329,11 @@ func TestLifecycleHookIntegration_SuspendedAndErrorDeregister(t *testing.T) { // --- Seed project, SA --- projectID := uuid.New().String() require.NoError(t, s.CreateProject(ctx, &store.Project{ - ID: projectID, - Name: "suspend-project", - Slug: "suspend-project", - Visibility: "private", - Created: time.Now(), - Updated: time.Now(), + ID: projectID, + Name: "suspend-project", + Slug: "suspend-project", + Created: time.Now(), + Updated: time.Now(), })) saID := uuid.New().String() @@ -476,12 +474,11 @@ func TestLifecycleHookIntegration_AgentRegistryA2AFlow(t *testing.T) { projectSlug := "my-scion-project" projectID := uuid.New().String() require.NoError(t, s.CreateProject(ctx, &store.Project{ - ID: projectID, - Name: "My Scion Project", - Slug: projectSlug, - Visibility: "private", - Created: time.Now(), - Updated: time.Now(), + ID: projectID, + Name: "My Scion Project", + Slug: projectSlug, + Created: time.Now(), + Updated: time.Now(), })) saID := uuid.New().String() diff --git a/pkg/hub/messagebroker_test.go b/pkg/hub/messagebroker_test.go index da7c830585..ef10f72f9f 100644 --- a/pkg/hub/messagebroker_test.go +++ b/pkg/hub/messagebroker_test.go @@ -131,10 +131,9 @@ func setupBrokerTestProject(t *testing.T, s store.Store) string { } project := &store.Project{ - ID: api.NewUUID(), - Name: "test-project", - Slug: "test-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "test-project", + Slug: "test-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) diff --git a/pkg/hub/notifications_conversation_test.go b/pkg/hub/notifications_conversation_test.go index 131823e87d..5a115a8e4c 100644 --- a/pkg/hub/notifications_conversation_test.go +++ b/pkg/hub/notifications_conversation_test.go @@ -33,10 +33,9 @@ func TestCreateInboxMessage_StampsConversationID(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "conv-stamp-project", - Slug: "conv-stamp-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "conv-stamp-project", + Slug: "conv-stamp-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -114,10 +113,9 @@ func TestCreateInboxMessage_NonUUIDSubscriber_NoStampNoPanic(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "conv-noid-project", - Slug: "conv-noid-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "conv-noid-project", + Slug: "conv-noid-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) diff --git a/pkg/hub/notifications_integration_test.go b/pkg/hub/notifications_integration_test.go index e2cf1ef29c..d12a47cba8 100644 --- a/pkg/hub/notifications_integration_test.go +++ b/pkg/hub/notifications_integration_test.go @@ -67,10 +67,9 @@ func setupIntegrationTest(t *testing.T) *integrationTestEnv { srv.SetDispatcher(recorder) project := &store.Project{ - ID: tid("project-integ"), - Name: "Integration Project", - Slug: "integration-project", - Visibility: store.VisibilityPrivate, + ID: tid("project-integ"), + Name: "Integration Project", + Slug: "integration-project", } require.NoError(t, s.CreateProject(ctx, project)) diff --git a/pkg/hub/notifications_test.go b/pkg/hub/notifications_test.go index 28f37360de..8fb05f2492 100644 --- a/pkg/hub/notifications_test.go +++ b/pkg/hub/notifications_test.go @@ -166,10 +166,9 @@ func setupNotificationTest(t *testing.T) *notificationTestEnv { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "Notification Test Project", - Slug: "notif-test-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "Notification Test Project", + Slug: "notif-test-project", } require.NoError(t, s.CreateProject(ctx, project)) diff --git a/pkg/hub/project_clone.go b/pkg/hub/project_clone.go index 6278e4d67d..cd05f9eb8c 100644 --- a/pkg/hub/project_clone.go +++ b/pkg/hub/project_clone.go @@ -124,7 +124,6 @@ func (s *Server) handleProjectClone(w http.ResponseWriter, r *http.Request, proj Slug: slug, GitRemote: src.GitRemote, DefaultRuntimeBrokerID: src.DefaultRuntimeBrokerID, - Visibility: store.VisibilityPrivate, CreatedBy: callerID, OwnerID: callerID, SharedDirs: src.SharedDirs, @@ -208,7 +207,6 @@ func (s *Server) handleProjectClone(w http.ResponseWriter, r *http.Request, proj clone.Labels = make(map[string]string) } clone.Labels[store.LabelTemplate] = "true" - clone.Visibility = store.VisibilityTeam } // ── Rollback stack ─────────────────────────────────────────────────── diff --git a/pkg/hub/project_clone_test.go b/pkg/hub/project_clone_test.go index 830089bf9d..9c14465449 100644 --- a/pkg/hub/project_clone_test.go +++ b/pkg/hub/project_clone_test.go @@ -31,7 +31,6 @@ func createSourceProject(t *testing.T, srv *Server, s store.Store) *store.Projec Slug: "source-project", GitRemote: "https://github.com/test/repo.git", DefaultRuntimeBrokerID: "broker-123", - Visibility: store.VisibilityPublic, OwnerID: DevUserID, CreatedBy: DevUserID, Annotations: map[string]string{ @@ -226,9 +225,6 @@ func TestProjectClone_HappyPath(t *testing.T) { assert.Equal(t, DevUserID, clone.OwnerID) assert.Equal(t, DevUserID, clone.CreatedBy) - // Visibility resets to private - assert.Equal(t, store.VisibilityPrivate, clone.Visibility) - // Settings annotations copied assert.Equal(t, "claude-sonnet", clone.Annotations["scion.io/default-model"]) assert.Equal(t, "100", clone.Annotations["scion.io/default-max-turns"]) @@ -360,12 +356,11 @@ func TestProjectClone_UnsetAnnotations(t *testing.T) { // Create a project with NO annotations set project := &store.Project{ - ID: api.NewUUID(), - Name: "Bare Project", - Slug: "bare-project", - Visibility: store.VisibilityPrivate, - OwnerID: DevUserID, - CreatedBy: DevUserID, + ID: api.NewUUID(), + Name: "Bare Project", + Slug: "bare-project", + OwnerID: DevUserID, + CreatedBy: DevUserID, } require.NoError(t, s.CreateProject(ctx, project)) @@ -385,12 +380,11 @@ func TestProjectClone_SlugOmitted_NameCollides(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "Original", - Slug: "original", - Visibility: store.VisibilityPrivate, - OwnerID: DevUserID, - CreatedBy: DevUserID, + ID: api.NewUUID(), + Name: "Original", + Slug: "original", + OwnerID: DevUserID, + CreatedBy: DevUserID, } require.NoError(t, s.CreateProject(ctx, project)) @@ -412,12 +406,11 @@ func TestProjectClone_ExplicitSlugCollides(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "Existing", - Slug: "existing-slug", - Visibility: store.VisibilityPrivate, - OwnerID: DevUserID, - CreatedBy: DevUserID, + ID: api.NewUUID(), + Name: "Existing", + Slug: "existing-slug", + OwnerID: DevUserID, + CreatedBy: DevUserID, } require.NoError(t, s.CreateProject(ctx, project)) @@ -469,12 +462,11 @@ func TestProjectClone_NoAgents(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "With Agents", - Slug: "with-agents", - Visibility: store.VisibilityPrivate, - OwnerID: DevUserID, - CreatedBy: DevUserID, + ID: api.NewUUID(), + Name: "With Agents", + Slug: "with-agents", + OwnerID: DevUserID, + CreatedBy: DevUserID, } require.NoError(t, s.CreateProject(ctx, project)) @@ -536,12 +528,11 @@ func TestProjectClone_Unauthenticated(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "Auth Test", - Slug: "auth-test", - Visibility: store.VisibilityPrivate, - OwnerID: DevUserID, - CreatedBy: DevUserID, + ID: api.NewUUID(), + Name: "Auth Test", + Slug: "auth-test", + OwnerID: DevUserID, + CreatedBy: DevUserID, } require.NoError(t, s.CreateProject(ctx, project)) @@ -556,12 +547,11 @@ func TestProjectClone_ReadOnly_Succeeds(t *testing.T) { // Project owned by the dev user — they can read it project := &store.Project{ - ID: api.NewUUID(), - Name: "Readable", - Slug: "readable", - Visibility: store.VisibilityPrivate, - OwnerID: DevUserID, - CreatedBy: DevUserID, + ID: api.NewUUID(), + Name: "Readable", + Slug: "readable", + OwnerID: DevUserID, + CreatedBy: DevUserID, } require.NoError(t, s.CreateProject(ctx, project)) @@ -583,12 +573,11 @@ func TestProjectClone_MissingName(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "Valid Project", - Slug: "valid-project", - Visibility: store.VisibilityPrivate, - OwnerID: DevUserID, - CreatedBy: DevUserID, + ID: api.NewUUID(), + Name: "Valid Project", + Slug: "valid-project", + OwnerID: DevUserID, + CreatedBy: DevUserID, } require.NoError(t, s.CreateProject(ctx, project)) @@ -602,12 +591,11 @@ func TestProjectClone_MethodNotAllowed(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "Method Test", - Slug: "method-test", - Visibility: store.VisibilityPrivate, - OwnerID: DevUserID, - CreatedBy: DevUserID, + ID: api.NewUUID(), + Name: "Method Test", + Slug: "method-test", + OwnerID: DevUserID, + CreatedBy: DevUserID, } require.NoError(t, s.CreateProject(ctx, project)) @@ -624,12 +612,11 @@ func TestProjectClone_ConcurrentClones(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "Concurrent Source", - Slug: "concurrent-source", - Visibility: store.VisibilityPrivate, - OwnerID: DevUserID, - CreatedBy: DevUserID, + ID: api.NewUUID(), + Name: "Concurrent Source", + Slug: "concurrent-source", + OwnerID: DevUserID, + CreatedBy: DevUserID, } require.NoError(t, s.CreateProject(ctx, project)) @@ -659,12 +646,11 @@ func TestProjectClone_AsTemplate_AdminOnly(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "Template Source", - Slug: "template-source", - Visibility: store.VisibilityPrivate, - OwnerID: DevUserID, - CreatedBy: DevUserID, + ID: api.NewUUID(), + Name: "Template Source", + Slug: "template-source", + OwnerID: DevUserID, + CreatedBy: DevUserID, } require.NoError(t, s.CreateProject(ctx, project)) @@ -678,9 +664,6 @@ func TestProjectClone_AsTemplate_AdminOnly(t *testing.T) { // Assert clone has scion.io/template: "true" label assert.Equal(t, "true", clone.Labels[store.LabelTemplate]) - - // Assert clone visibility is "team" - assert.Equal(t, store.VisibilityTeam, clone.Visibility) } func TestProjectClone_AsTemplate_ScionIOLabelsStripped_WhenNoAsTemplate(t *testing.T) { @@ -689,12 +672,11 @@ func TestProjectClone_AsTemplate_ScionIOLabelsStripped_WhenNoAsTemplate(t *testi // Create source with scion.io/template label project := &store.Project{ - ID: api.NewUUID(), - Name: "Existing Template", - Slug: "existing-template", - Visibility: store.VisibilityTeam, - OwnerID: DevUserID, - CreatedBy: DevUserID, + ID: api.NewUUID(), + Name: "Existing Template", + Slug: "existing-template", + OwnerID: DevUserID, + CreatedBy: DevUserID, Labels: map[string]string{ store.LabelTemplate: "true", "team": "backend", @@ -716,8 +698,6 @@ func TestProjectClone_AsTemplate_ScionIOLabelsStripped_WhenNoAsTemplate(t *testi // Assert non-system label IS preserved assert.Equal(t, "backend", clone.Labels["team"]) - // Assert visibility resets to private (not inherited from source) - assert.Equal(t, store.VisibilityPrivate, clone.Visibility) } func TestProjectClone_StorageFilesCopied(t *testing.T) { diff --git a/pkg/hub/project_pre_start_hook_handlers_test.go b/pkg/hub/project_pre_start_hook_handlers_test.go index 6e64d792a4..c1d14d2e9f 100644 --- a/pkg/hub/project_pre_start_hook_handlers_test.go +++ b/pkg/hub/project_pre_start_hook_handlers_test.go @@ -31,11 +31,10 @@ import ( func createTestProjectForPSH(t *testing.T, s store.Store) *store.Project { t.Helper() project := &store.Project{ - ID: tid("test-project-psh-" + t.Name()), - Name: "Test Project PSH", - Slug: "test-project-psh-" + strings.ToLower(t.Name()), - Visibility: "private", - OwnerID: "dev@localhost", + ID: tid("test-project-psh-" + t.Name()), + Name: "Test Project PSH", + Slug: "test-project-psh-" + strings.ToLower(t.Name()), + OwnerID: "dev@localhost", } require.NoError(t, s.CreateProject(t.Context(), project)) return project diff --git a/pkg/hub/project_settings_handlers_test.go b/pkg/hub/project_settings_handlers_test.go index 9c8b0e6a5d..efacb32bbc 100644 --- a/pkg/hub/project_settings_handlers_test.go +++ b/pkg/hub/project_settings_handlers_test.go @@ -717,10 +717,9 @@ func TestProjectSettings_DefaultGCPIdentity_OtherProjectSAIsNotAnOracle(t *testi project := createTestProjectForSettings(t, s) other := &store.Project{ - ID: tid("other-project-" + t.Name()), - Name: "Other Project", - Slug: "other-project", - Visibility: "private", + ID: tid("other-project-" + t.Name()), + Name: "Other Project", + Slug: "other-project", } require.NoError(t, s.CreateProject(t.Context(), other)) otherSA := newSettingsTestSA(t, s, other.ID, "sa-elsewhere") @@ -1039,10 +1038,9 @@ func TestProjectSettings_MaxAgentRole_ClearValue(t *testing.T) { func createTestProjectForSettings(t *testing.T, s store.Store) *store.Project { t.Helper() project := &store.Project{ - ID: tid("test-project-settings-" + t.Name()), - Name: "Test Project", - Slug: "test-project-settings", - Visibility: "private", + ID: tid("test-project-settings-" + t.Name()), + Name: "Test Project", + Slug: "test-project-settings", } require.NoError(t, s.CreateProject(t.Context(), project)) return project diff --git a/pkg/hub/project_settings_resolved_test.go b/pkg/hub/project_settings_resolved_test.go index 29a4769223..ead379a409 100644 --- a/pkg/hub/project_settings_resolved_test.go +++ b/pkg/hub/project_settings_resolved_test.go @@ -431,21 +431,12 @@ func TestResolvedSettings_EndpointUnknownProject(t *testing.T) { } // TestResolvedSettings_NonHubMemberForbidden asserts that the Forbidden branch -// of this endpoint is REACHABLE and correct: a caller who is not a hub member -// gets 403. +// of this endpoint is REACHABLE and correct: callers without project read +// access get 403. // -// Read the wording carefully, because the obvious version of this test cannot -// be written on this platform. The seeded hub-member-read-all policy grants -// ActionRead on ALL projects to EVERY hub member, so a caller who is merely not -// a member OF THIS PROJECT is legitimately authorized and gets 200. A test -// named "non-member is refused" would have had to assert 200 in order to pass, -// which is very likely why no negative test existed here in the first place. -// The meaningful denial is therefore a non-HUB-member, and that is what this -// asserts. -// -// Both facts are pinned below, the surprising one deliberately, so that the -// next person to read this does not "fix" the 200 into a 403 and break the -// platform's actual access model. +// After narrowing hub-member-read-all (proj-visibility), project reads are +// gated by per-project membership policies. Non-project-members now correctly +// get 403, regardless of hub membership. func TestResolvedSettings_NonHubMemberForbidden(t *testing.T) { srv, s, _, hubMemberNonProjectMember, project := setupDemoPolicyTest(t) @@ -464,31 +455,25 @@ func TestResolvedSettings_NonHubMemberForbidden(t *testing.T) { resolvedPath := "/api/v1/projects/" + project.ID + "/settings/resolved" settingsPath := "/api/v1/projects/" + project.ID + "/settings" - // The assertion the lead asked for: the Forbidden branch is reachable. + // Non-hub-member: refused. rec := doRequestAsUser(t, srv, outsider, http.MethodGet, resolvedPath, nil) assert.Equalf(t, http.StatusForbidden, rec.Code, "a non-hub-member must be refused by the resolved endpoint; body: %s", rec.Body.String()) // Control against the pre-existing endpoint this handler was modelled on. - // If GET /settings ever stops refusing this caller, the line above is - // asserting a local quirk rather than the platform's policy, and the two - // should be changed together rather than drifting apart. rec = doRequestAsUser(t, srv, outsider, http.MethodGet, settingsPath, nil) assert.Equalf(t, http.StatusForbidden, rec.Code, "control: the pre-existing GET /settings must refuse the same caller, "+ "otherwise /settings/resolved is enforcing a policy of its own; body: %s", rec.Body.String()) - // The counter-intuitive half. This caller is NOT a member of the project, - // and is nonetheless authorized, because hub-member-read-all grants read on - // every project to every hub member. This is pinned as correct-as-designed. - // If it ever returns 403, the hub's read model has changed and this - // endpoint's authorization needs rereading — do not simply update the - // expectation. + // Hub member who is NOT a project member: also refused. After narrowing + // hub-member-read-all to exclude project resources, project reads require + // membership in the project's members group. rec = doRequestAsUser(t, srv, hubMemberNonProjectMember, http.MethodGet, resolvedPath, nil) - assert.Equalf(t, http.StatusOK, rec.Code, - "a hub member who is not a project member is granted read by "+ - "hub-member-read-all and must get 200, not 403; body: %s", + assert.Equalf(t, http.StatusForbidden, rec.Code, + "a hub member who is not a project member should get 403 after "+ + "hub-member-read-all was narrowed; body: %s", rec.Body.String()) } diff --git a/pkg/hub/project_visibility_test.go b/pkg/hub/project_visibility_test.go new file mode 100644 index 0000000000..3558096a7e --- /dev/null +++ b/pkg/hub/project_visibility_test.go @@ -0,0 +1,366 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !no_sqlite + +package hub + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================================= +// Project visibility via membership-based policies +// +// After narrowing hub-member-read-all to exclude project/agent/broker resources, +// project visibility is controlled by per-project membership policies: +// - Project members (in the project::members group) can read the project +// - Non-members cannot read the project (get 404) +// - Adding the hub-members group to the project's members group makes it +// visible to all hub members ("everyone" / "public" visibility) +// ============================================================================= + +// TestGetProject_MemberCanRead verifies that a project member can read their +// project via the getProject handler. +func TestGetProject_MemberCanRead(t *testing.T) { + srv, _, alice, _, project := setupDemoPolicyTest(t) + + rec := doRequestAsUser(t, srv, alice, http.MethodGet, + "/api/v1/projects/"+project.ID, nil) + require.Equal(t, http.StatusOK, rec.Code, + "project member should see their project; got: %s", rec.Body.String()) + + var resp ProjectWithCapabilities + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + assert.Equal(t, project.ID, resp.ID) +} + +// TestGetProject_NonMemberGetNotFound verifies that a hub member who is NOT a +// project member receives 404 (not 403) when reading the project. +func TestGetProject_NonMemberGetNotFound(t *testing.T) { + srv, _, _, bob, project := setupDemoPolicyTest(t) + + rec := doRequestAsUser(t, srv, bob, http.MethodGet, + "/api/v1/projects/"+project.ID, nil) + assert.Equal(t, http.StatusNotFound, rec.Code, + "non-member should get 404; got: %s", rec.Body.String()) +} + +// TestGetProject_UnauthenticatedGetNotFound verifies that an unauthenticated +// caller receives 404 when attempting to read any project. +func TestGetProject_UnauthenticatedGetNotFound(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + project := &store.Project{ + ID: tid("vis-unauth-proj"), + Name: "Visibility Unauth Project", + Slug: "vis-unauth-proj", + Created: time.Now(), + Updated: time.Now(), + } + require.NoError(t, s.CreateProject(ctx, project)) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+project.ID, nil) + rec := httptest.NewRecorder() + srv.getProject(rec, req, project.ID) + assert.Equal(t, http.StatusNotFound, rec.Code, + "unauthenticated caller should get 404; got: %s", rec.Body.String()) +} + +// TestListProjects_MemberSeesOwnProject verifies that a project member can see +// their project in the list response. +func TestListProjects_MemberSeesOwnProject(t *testing.T) { + srv, _, alice, _, project := setupDemoPolicyTest(t) + + rec := doRequestAsUser(t, srv, alice, http.MethodGet, "/api/v1/projects", nil) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var resp ListProjectsResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + + found := false + for _, p := range resp.Projects { + if p.ID == project.ID { + found = true + break + } + } + assert.True(t, found, "project member should see their project in list") +} + +// TestListProjects_NonMemberDoesNotSeeProject verifies that a hub member who is +// NOT a project member does not see that project in list responses. +func TestListProjects_NonMemberDoesNotSeeProject(t *testing.T) { + srv, _, _, bob, project := setupDemoPolicyTest(t) + + rec := doRequestAsUser(t, srv, bob, http.MethodGet, "/api/v1/projects", nil) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + + var resp ListProjectsResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) + + for _, p := range resp.Projects { + assert.NotEqual(t, project.ID, p.ID, + "non-member should NOT see the project in list") + } +} + +// TestProjectVisibility_HubMembersGroupMakesProjectPublic verifies the "everyone" +// visibility pattern: adding the hub-members group to a project's members group +// makes the project visible to all hub members via transitive group expansion. +func TestProjectVisibility_HubMembersGroupMakesProjectPublic(t *testing.T) { + srv, s, _, bob, project := setupDemoPolicyTest(t) + ctx := context.Background() + + // Bob is a hub member but NOT a project member — verify he can't see the project. + rec := doRequestAsUser(t, srv, bob, http.MethodGet, + "/api/v1/projects/"+project.ID, nil) + require.Equal(t, http.StatusNotFound, rec.Code, + "before adding hub-members group, non-member should get 404") + + // Add the hub-members group to the project's members group (nested group). + // This is the "make project public" operation. + hubMembersGroup, err := s.GetGroupBySlug(ctx, "hub-members") + require.NoError(t, err, "hub-members group should exist") + + projectMembersGroup, err := s.GetGroupBySlug(ctx, "project:"+project.Slug+":members") + require.NoError(t, err, "project members group should exist") + + err = s.AddGroupMember(ctx, &store.GroupMember{ + GroupID: projectMembersGroup.ID, + MemberType: store.GroupMemberTypeGroup, + MemberID: hubMembersGroup.ID, + Role: store.GroupMemberRoleMember, + }) + require.NoError(t, err, "adding hub-members group to project members should succeed") + + // Now bob (hub member) should be able to see the project through transitive membership. + rec = doRequestAsUser(t, srv, bob, http.MethodGet, + "/api/v1/projects/"+project.ID, nil) + assert.Equal(t, http.StatusOK, rec.Code, + "after adding hub-members group, any hub member should see the project; got: %s", rec.Body.String()) +} + +// TestProjectVisibility_HubMembersGroupMakesProjectVisibleInList verifies that +// after adding hub-members to the project members group, the project appears in +// the list response for all hub members. +func TestProjectVisibility_HubMembersGroupMakesProjectVisibleInList(t *testing.T) { + srv, s, _, bob, project := setupDemoPolicyTest(t) + ctx := context.Background() + + // Verify bob can't see the project in list before. + rec := doRequestAsUser(t, srv, bob, http.MethodGet, "/api/v1/projects", nil) + require.Equal(t, http.StatusOK, rec.Code) + var beforeResp ListProjectsResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&beforeResp)) + for _, p := range beforeResp.Projects { + require.NotEqual(t, project.ID, p.ID, + "non-member should not see project in list before making public") + } + + // Make the project public by adding hub-members to project members. + hubMembersGroup, err := s.GetGroupBySlug(ctx, "hub-members") + require.NoError(t, err) + projectMembersGroup, err := s.GetGroupBySlug(ctx, "project:"+project.Slug+":members") + require.NoError(t, err) + require.NoError(t, s.AddGroupMember(ctx, &store.GroupMember{ + GroupID: projectMembersGroup.ID, + MemberType: store.GroupMemberTypeGroup, + MemberID: hubMembersGroup.ID, + Role: store.GroupMemberRoleMember, + })) + + // Now bob should see the project in list. + rec = doRequestAsUser(t, srv, bob, http.MethodGet, "/api/v1/projects", nil) + require.Equal(t, http.StatusOK, rec.Code) + var afterResp ListProjectsResponse + require.NoError(t, json.NewDecoder(rec.Body).Decode(&afterResp)) + found := false + for _, p := range afterResp.Projects { + if p.ID == project.ID { + found = true + break + } + } + assert.True(t, found, "after making project public, any hub member should see it in list") +} + +// TestGetProject_CheckAccess_MemberReadDecision verifies at the AuthzService +// level that a project member gets an allowed decision for read on their project. +func TestGetProject_CheckAccess_MemberReadDecision(t *testing.T) { + srv, _, alice, _, project := setupDemoPolicyTest(t) + ctx := context.Background() + + identity := NewAuthenticatedUser(alice.ID, alice.Email, alice.DisplayName, "member", "api") + decision := srv.authzService.CheckAccess(ctx, identity, projectResource(project), ActionRead) + assert.True(t, decision.Allowed, + "project member should be allowed to read project; reason=%q", decision.Reason) +} + +// TestGetProject_CheckAccess_NonMemberReadDenied verifies at the AuthzService +// level that a non-member gets a denied decision for read on a project. +func TestGetProject_CheckAccess_NonMemberReadDenied(t *testing.T) { + srv, _, _, bob, project := setupDemoPolicyTest(t) + ctx := context.Background() + + identity := NewAuthenticatedUser(bob.ID, bob.Email, bob.DisplayName, "member", "api") + decision := srv.authzService.CheckAccess(ctx, identity, projectResource(project), ActionRead) + assert.False(t, decision.Allowed, + "non-member should be denied read on project; reason=%q", decision.Reason) +} + +// TestNarrowHubMemberReadAll_DeletesWildcardPolicy verifies that the +// narrowHubMemberReadAll function deletes a wildcard hub-member-read-all policy +// and that per-type policies are seeded for directory resources only. +func TestNarrowHubMemberReadAll_DeletesWildcardPolicy(t *testing.T) { + _, s := testServer(t) + ctx := context.Background() + + // After testServer/seedDefaultPoliciesAndGroups, the wildcard policy should + // be gone and per-type policies should exist. + wildcardPolicies, err := s.ListPolicies(ctx, store.PolicyFilter{ + Name: "hub-member-read-all", + ScopeType: "hub", + }, store.ListOptions{Limit: 10}) + require.NoError(t, err) + assert.Empty(t, wildcardPolicies.Items, + "wildcard hub-member-read-all should be deleted after narrowing") + + // Per-type policies for globally readable resources should exist. + for _, rt := range []string{"user", "group", "template", "harness_config", "broker", "runtime_broker", "gcp_service_account", "policy", "skill", "quota", "role", "role_binding", "hub"} { + policies, err := s.ListPolicies(ctx, store.PolicyFilter{ + Name: "hub-member-read-" + rt, + ScopeType: "hub", + }, store.ListOptions{Limit: 1}) + require.NoError(t, err) + require.NotEmpty(t, policies.Items, + "hub-member-read-%s policy should be seeded", rt) + assert.Equal(t, rt, policies.Items[0].ResourceType, + "policy resource type should be %s", rt) + assert.Equal(t, []string{"read", "list"}, policies.Items[0].Actions) + } + + // No per-type policy should exist for project or agent + // (those are now gated per-project by membership policies). + for _, rt := range []string{"project", "agent"} { + policies, err := s.ListPolicies(ctx, store.PolicyFilter{ + Name: "hub-member-read-" + rt, + ScopeType: "hub", + }, store.ListOptions{Limit: 1}) + require.NoError(t, err) + assert.Empty(t, policies.Items, + "hub-member-read-%s should NOT exist as a hub-scoped policy", rt) + } +} + +// TestEnsureProjectMemberReadPolicy_CreatesReadPolicies verifies that +// ensureProjectMemberReadPolicy creates read+list policies for project and agent +// resource types bound to the project's members group. +func TestEnsureProjectMemberReadPolicy_CreatesReadPolicies(t *testing.T) { + _, s, _, _, project := setupDemoPolicyTest(t) + ctx := context.Background() + + for _, rt := range []string{"project", "agent"} { + policyName := "project:" + project.Slug + ":member-read-" + rt + policies, err := s.ListPolicies(ctx, store.PolicyFilter{ + Name: policyName, + }, store.ListOptions{Limit: 1}) + require.NoError(t, err) + require.NotEmpty(t, policies.Items, + "member-read-%s policy should exist for project %s", rt, project.Slug) + + policy := policies.Items[0] + assert.Equal(t, "project", policy.ScopeType) + assert.Equal(t, project.ID, policy.ScopeID) + assert.Equal(t, rt, policy.ResourceType) + assert.Equal(t, []string{"read", "list"}, policy.Actions) + assert.Equal(t, "allow", policy.Effect) + + // Verify binding to members group + membersGroup, err := s.GetGroupBySlug(ctx, "project:"+project.Slug+":members") + require.NoError(t, err) + bindings, err := s.GetPolicyBindings(ctx, policy.ID) + require.NoError(t, err) + found := false + for _, b := range bindings { + if b.PrincipalType == "group" && b.PrincipalID == membersGroup.ID { + found = true + break + } + } + assert.True(t, found, + "member-read-%s policy should be bound to members group", rt) + } +} + +// TestProjectVisibility_NewProjectCreatorCanRead verifies that when a project is +// created via the HTTP API, the creator automatically has read access. +func TestProjectVisibility_NewProjectCreatorCanRead(t *testing.T) { + srv, s := testServer(t) + ctx := context.Background() + + // Create a non-admin user. + creator := &store.User{ + ID: tid("vis-creator"), + Email: "creator@test.com", + DisplayName: "Creator", + Role: store.UserRoleMember, + Status: "active", + Created: time.Now(), + } + require.NoError(t, s.CreateUser(ctx, creator)) + ensureHubMembership(ctx, s, creator.ID) + + // Create a project via the API as this user. + rec := doRequestAsUser(t, srv, creator, http.MethodPost, "/api/v1/projects", + CreateProjectRequest{Name: "Creator's Project"}) + require.Equal(t, http.StatusCreated, rec.Code, + "project creation should succeed; got: %s", rec.Body.String()) + + var createdProject store.Project + require.NoError(t, json.NewDecoder(rec.Body).Decode(&createdProject)) + + // Creator should be able to read the project. + rec = doRequestAsUser(t, srv, creator, http.MethodGet, + "/api/v1/projects/"+createdProject.ID, nil) + assert.Equal(t, http.StatusOK, rec.Code, + "project creator should be able to read their project; got: %s", rec.Body.String()) + + // Another user should NOT be able to read it. + outsider := &store.User{ + ID: tid("vis-outsider"), + Email: "outsider@test.com", + DisplayName: "Outsider", + Role: store.UserRoleMember, + Status: "active", + Created: time.Now(), + } + require.NoError(t, s.CreateUser(ctx, outsider)) + ensureHubMembership(ctx, s, outsider.ID) + + rec = doRequestAsUser(t, srv, outsider, http.MethodGet, + "/api/v1/projects/"+createdProject.ID, nil) + assert.Equal(t, http.StatusNotFound, rec.Code, + "outsider should get 404 for project they're not a member of; got: %s", rec.Body.String()) +} diff --git a/pkg/hub/publish_guard_test.go b/pkg/hub/publish_guard_test.go index aae32bf11b..d14eafee39 100644 --- a/pkg/hub/publish_guard_test.go +++ b/pkg/hub/publish_guard_test.go @@ -169,10 +169,9 @@ func TestHandleAgentMessage_SkipsPublishOnPersistFailure(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "guard-project", - Slug: "guard-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "guard-project", + Slug: "guard-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -224,10 +223,9 @@ func TestHandleAgentMessage_ResponseStatusNotDeliveredOnPersistFailure(t *testin ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "status-project", - Slug: "status-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "status-project", + Slug: "status-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -293,10 +291,9 @@ func TestHandleAgentMessage_PublishesOnPersistSuccess(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "ok-project", - Slug: "ok-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "ok-project", + Slug: "ok-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -351,10 +348,9 @@ func TestHandleGroupMessage_SkipsPublishOnPersistFailure(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "group-project", - Slug: "group-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "group-project", + Slug: "group-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -433,10 +429,9 @@ func TestHandleGroupMessage_PublishesOnPersistSuccess(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "group-ok-project", - Slug: "group-ok-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "group-ok-project", + Slug: "group-ok-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -520,10 +515,9 @@ func TestProcessMentions_SkipsPublishOnPersistFailure(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "mention-project", - Slug: "mention-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "mention-project", + Slug: "mention-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) @@ -591,10 +585,9 @@ func TestProcessMentions_PublishesOnPersistSuccess(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "mention-ok-project", - Slug: "mention-ok-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "mention-ok-project", + Slug: "mention-ok-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("CreateProject: %v", err) diff --git a/pkg/hub/reconcile_test.go b/pkg/hub/reconcile_test.go index 5ba1a2d7b4..1e9aa7fd1f 100644 --- a/pkg/hub/reconcile_test.go +++ b/pkg/hub/reconcile_test.go @@ -121,7 +121,7 @@ func TestReconcileBroker_SkipsPendingMessages(t *testing.T) { func(context.Context, *store.Message) error { atomic.AddInt32(&deliverN, 1); return nil }) broker := uuid.NewString() - proj := &store.Project{ID: uuid.NewString(), Name: "p", Slug: "p-" + uuid.NewString()[:8], Visibility: store.VisibilityPrivate, OwnerID: uuid.NewString()} + proj := &store.Project{ID: uuid.NewString(), Name: "p", Slug: "p-" + uuid.NewString()[:8], OwnerID: uuid.NewString()} require.NoError(t, cs.CreateProject(ctx, proj)) _, err := client.Agent.Create(). SetSlug("a-" + uuid.NewString()[:8]).SetName("a"). @@ -141,7 +141,7 @@ func TestDeliverMessage_TunnelsViaDispatcher(t *testing.T) { client := enttest.NewClient(t) cs := entadapter.NewCompositeStore(client) - proj := &store.Project{ID: uuid.NewString(), Name: "p", Slug: "p-" + uuid.NewString()[:8], Visibility: store.VisibilityPrivate, OwnerID: uuid.NewString()} + proj := &store.Project{ID: uuid.NewString(), Name: "p", Slug: "p-" + uuid.NewString()[:8], OwnerID: uuid.NewString()} require.NoError(t, cs.CreateProject(ctx, proj)) brokerID := uuid.NewString() diff --git a/pkg/hub/resource_source_test.go b/pkg/hub/resource_source_test.go index 0e9c52d7e4..b249211aca 100644 --- a/pkg/hub/resource_source_test.go +++ b/pkg/hub/resource_source_test.go @@ -335,7 +335,6 @@ func TestBootstrapSource_SkipNonBuiltinConflict(t *testing.T) { StoragePath: "templates/global/conflict", StorageBucket: stor.Bucket(), StorageURI: "gs://test-bucket/templates/global/conflict", - Visibility: store.VisibilityPrivate, } if err := s.CreateTemplate(ctx, existing); err != nil { t.Fatalf("pre-create failed: %v", err) @@ -393,7 +392,6 @@ func TestBootstrapSource_DuplicateCreateRace(t *testing.T) { StoragePath: "templates/global/raced", StorageBucket: "test-bucket", StorageURI: "gs://test-bucket/templates/global/raced", - Visibility: store.VisibilityPrivate, } if err := s.CreateTemplate(ctx, existing); err != nil { t.Fatalf("pre-create failed: %v", err) @@ -504,7 +502,6 @@ func TestBootstrapSource_OverwriteAlways(t *testing.T) { StoragePath: "templates/global/admin-override", StorageBucket: stor.Bucket(), StorageURI: "gs://test-bucket/templates/global/admin-override", - Visibility: store.VisibilityPrivate, } if err := s.CreateTemplate(ctx, existing); err != nil { t.Fatalf("pre-create failed: %v", err) diff --git a/pkg/hub/resource_validate_test.go b/pkg/hub/resource_validate_test.go index ebbf5742b1..3937c8858b 100644 --- a/pkg/hub/resource_validate_test.go +++ b/pkg/hub/resource_validate_test.go @@ -174,7 +174,6 @@ func TestValidateStorage_ZeroFilesActive(t *testing.T) { StoragePath: "templates/global/zero-files", StorageBucket: stor.Bucket(), StorageURI: "gs://test-bucket/templates/global/zero-files", - Visibility: store.VisibilityPrivate, Files: nil, } if err := s.CreateTemplate(ctx, tmpl); err != nil { @@ -469,7 +468,6 @@ func TestPartialUpload_ResourceStaysPending(t *testing.T) { StoragePath: "templates/global/partial", StorageBucket: "test-bucket", StorageURI: "gs://test-bucket/templates/global/partial", - Visibility: store.VisibilityPrivate, Files: []store.TemplateFile{ {Path: "scion-agent.yaml", Size: 20}, }, diff --git a/pkg/hub/sa_existence_oracle_test.go b/pkg/hub/sa_existence_oracle_test.go index 7c441e547b..3f29f47fce 100644 --- a/pkg/hub/sa_existence_oracle_test.go +++ b/pkg/hub/sa_existence_oracle_test.go @@ -245,10 +245,9 @@ func TestSAOracle_ProjectDefault_MissingAndUnreachableAreOneAnswer(t *testing.T) project := createTestProjectForSettings(t, s) elsewhere := &store.Project{ - ID: tid("oracle-other-project"), - Name: "Oracle Other", - Slug: "oracle-other-project", - Visibility: "private", + ID: tid("oracle-other-project"), + Name: "Oracle Other", + Slug: "oracle-other-project", } require.NoError(t, s.CreateProject(t.Context(), elsewhere)) diff --git a/pkg/hub/scoped_admin_test.go b/pkg/hub/scoped_admin_test.go index 96cc4f01df..8119f6d74c 100644 --- a/pkg/hub/scoped_admin_test.go +++ b/pkg/hub/scoped_admin_test.go @@ -177,10 +177,10 @@ func TestScopedAdmin_HubAdminAccessesDiagnosticsLogsViaReadAllPolicy(t *testing. srv, _, hubAdmin, _ := setupScopedAdminTest(t) // GET /api/v1/admin/diagnostics/logs — permission: hub.diagnostics.read (NOT in hub-admin role) - // NOTE: The route guard action is "read" and the hub-member-read-all seeded policy - // grants read+list on * to all hub members. This means diagnostics/logs (action=read) - // is accessible via policy evaluation even though it is not in the hub-admin role. - // The test documents the actual behavior. + // NOTE: The route guard action is "read" and the hub-member-read-hub seeded policy + // grants read+list on hub resources to all hub members. This means diagnostics/logs + // (action=read) is accessible via policy evaluation even though it is not in the + // hub-admin role. The test documents the actual behavior. // // When Cloud Logging is not configured (no GCP project ID), the handler // returns 501 after passing authorization. Both 200 and 501 confirm the @@ -211,20 +211,20 @@ func TestScopedAdmin_HubAdminAccessesPolicyListViaReadAllPolicy(t *testing.T) { srv, _, hubAdmin, _ := setupScopedAdminTest(t) // GET /api/v1/policies — RouteHubAdmin, permission: policy.read (NOT in hub-admin role) - // NOTE: The route guard action is "read" and the hub-member-read-all seeded policy - // grants read+list on * to all hub members. Policy listing (action=read) is therefore - // accessible via policy evaluation. Policy CREATION (action=create via handler-level - // CanDelegate with GrantTypePolicy) remains super-admin-only. + // NOTE: The route guard action is "read" and the hub-member-read-policy seeded policy + // grants read+list on policy resources to all hub members. Policy listing (action=read) + // is therefore accessible via policy evaluation. Policy CREATION (action=create via + // handler-level CanDelegate with GrantTypePolicy) remains super-admin-only. rec := doRequestAsUser(t, srv, hubAdmin, http.MethodGet, "/api/v1/policies", nil) assert.Equal(t, http.StatusOK, rec.Code, - "hub-admin should access policy listing via hub-member-read-all policy (action=read)") + "hub-admin should access policy listing via per-type hub-member-read-* policies policy (action=read)") } func TestScopedAdmin_HubAdminDeniedPolicyCreate(t *testing.T) { srv, _, hubAdmin, _ := setupScopedAdminTest(t) // POST /api/v1/policies — The route guard passes (metadata action=read matches - // hub-member-read-all policy). The handler enforces policy.create permission + // hub-member-read-policy policy). The handler enforces policy.create permission // via its own Decide call (handlers_policies.go), which denies non-super-admins. rec := doRequestAsUser(t, srv, hubAdmin, http.MethodPost, "/api/v1/policies", map[string]interface{}{ "name": "test-policy", @@ -235,8 +235,8 @@ func TestScopedAdmin_HubAdminDeniedPolicyCreate(t *testing.T) { } // Verify that a regular member (no role binding) is denied admin endpoints -// whose actions are NOT covered by the hub-member-read-all policy (read/list). -// Endpoints with action=read are accessible to all hub members via that policy. +// whose actions are NOT covered by per-type hub-member-read policies (read/list). +// Endpoints with action=read are accessible to all hub members via those policies. func TestScopedAdmin_RegularMemberDeniedWriteAdminEndpoints(t *testing.T) { srv, _, _, member := setupScopedAdminTest(t) @@ -263,12 +263,12 @@ func TestScopedAdmin_RegularMemberDeniedWriteAdminEndpoints(t *testing.T) { } // Verify that read-action admin endpoints ARE accessible to regular hub members -// via the hub-member-read-all seeded policy (read+list on * resource type). +// via the per-type hub-member-read-* policies seeded policy (read+list on each resource type). func TestScopedAdmin_RegularMemberAllowedReadAdminEndpoints(t *testing.T) { srv, _, _, member := setupScopedAdminTest(t) - // Endpoints where action=read — hub-member-read-all policy grants these. + // Endpoints where action=read — per-type hub-member-read-* policies policy grants these. tests := []struct { name string path string @@ -284,7 +284,7 @@ func TestScopedAdmin_RegularMemberAllowedReadAdminEndpoints(t *testing.T) { t.Run(tt.name, func(t *testing.T) { rec := doRequestAsUser(t, srv, member, http.MethodGet, tt.path, nil) assert.Equal(t, http.StatusOK, rec.Code, - "hub member should access %s via hub-member-read-all policy (action=read)", tt.path) + "hub member should access %s via per-type hub-member-read-* policies policy (action=read)", tt.path) }) } } @@ -396,7 +396,7 @@ func TestScopedAdmin_ProjectAdminDeniedHubLevelWriteOperations(t *testing.T) { srv, _, projectAdmin, _, _ := setupProjectScopedAdminTest(t) // A project-scoped admin should NOT be able to perform hub-level write operations. - // Read-action endpoints are accessible to all hub members via hub-member-read-all policy, + // Read-action endpoints are accessible to all hub members via per-type hub-member-read-* policies policy, // but write/execute/update operations require specific permissions the project-admin lacks. tests := []struct { name string @@ -421,7 +421,7 @@ func TestScopedAdmin_ProjectAdminDeniedHubLevelWriteOperations(t *testing.T) { func TestScopedAdmin_ProjectAdminAllowedHubReadOperations(t *testing.T) { srv, _, projectAdmin, _, _ := setupProjectScopedAdminTest(t) - // Project-scoped admin is also a hub member, so the hub-member-read-all + // Project-scoped admin is also a hub member, so the per-type hub-member-read-* policies // policy grants read access to hub-level admin endpoints. tests := []struct { name string @@ -437,7 +437,7 @@ func TestScopedAdmin_ProjectAdminAllowedHubReadOperations(t *testing.T) { t.Run(tt.name, func(t *testing.T) { rec := doRequestAsUser(t, srv, projectAdmin, http.MethodGet, tt.path, nil) assert.Equal(t, http.StatusOK, rec.Code, - "project admin (hub member) should access %s via hub-member-read-all policy", tt.path) + "project admin (hub member) should access %s via per-type hub-member-read-* policies policy", tt.path) }) } } @@ -630,17 +630,17 @@ func TestScopedAdmin_CustomNarrowRole(t *testing.T) { require.NoError(t, err) // Read-action admin endpoints are accessible to all hub members via - // hub-member-read-all policy, regardless of role bindings. + // per-type hub-member-read-* policies policy, regardless of role bindings. rec := doRequestAsUser(t, srv, narrowUser, http.MethodGet, "/api/v1/admin/server-config", nil) assert.Equal(t, http.StatusOK, rec.Code, - "narrow-role user (hub member) should access server config via hub-member-read-all") + "narrow-role user (hub member) should access server config via per-type hub-member-read-* policies") rec = doRequestAsUser(t, srv, narrowUser, http.MethodGet, "/api/v1/admin/roles", nil) assert.Equal(t, http.StatusOK, rec.Code, - "narrow-role user (hub member) should access roles via hub-member-read-all") + "narrow-role user (hub member) should access roles via per-type hub-member-read-* policies") // But non-read-action admin endpoints should be denied — the narrow role - // doesn't include the required permissions, and hub-member-read-all + // doesn't include the required permissions, and per-type hub-member-read-* policies // only covers read+list. rec = doRequestAsUser(t, srv, narrowUser, http.MethodGet, "/api/v1/skill-registries", nil) assert.Equal(t, http.StatusForbidden, rec.Code, @@ -673,12 +673,12 @@ func TestScopedAdmin_HubAdminDeniedPolicyAuthoring(t *testing.T) { srv, _, hubAdmin, _ := setupScopedAdminTest(t) t.Run("policy_list_allowed_via_read_all", func(t *testing.T) { - // GET /api/v1/policies — action=read passes via hub-member-read-all policy. + // GET /api/v1/policies — action=read passes via per-type hub-member-read-* policies policy. // Policy listing is safe to expose to hub members; it is the create/update/delete // operations that remain super-admin-only (enforced by CanDelegate GrantTypePolicy). rec := doRequestAsUser(t, srv, hubAdmin, http.MethodGet, "/api/v1/policies", nil) assert.Equal(t, http.StatusOK, rec.Code, - "hub-admin should access policy listing via hub-member-read-all (action=read)") + "hub-admin should access policy listing via per-type hub-member-read-* policies (action=read)") }) t.Run("policy_create_denied", func(t *testing.T) { @@ -769,7 +769,7 @@ func TestScopedAdmin_CanDelegateMixedPermissions(t *testing.T) { t.Run("one_unheld_permission_denied", func(t *testing.T) { // Mix of held + unheld: user.read (held) + hub.admin_mode.update (not held, - // and action=update is NOT granted by the hub-member-read-all policy). + // and action=update is NOT granted by the per-type hub-member-read-* policies policy). rec := doRequestAsUser(t, srv, hubAdmin, http.MethodPost, "/api/v1/admin/roles", createRoleDefinitionRequest{ Name: "mixed-perm-denied", Description: "Mix of held and unheld permissions", diff --git a/pkg/hub/seed.go b/pkg/hub/seed.go index 7f98fd06d2..0d59649b32 100644 --- a/pkg/hub/seed.go +++ b/pkg/hub/seed.go @@ -16,6 +16,7 @@ package hub import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -50,17 +51,27 @@ func seedDefaultPoliciesAndGroups(ctx context.Context, s store.Store) { slog.Info("seeded hub-members group", "id", group.ID) } - // 2. Seed hub-member-read-all policy - seedPolicy(ctx, s, group.ID, &store.Policy{ - ID: api.NewUUID(), - Name: "hub-member-read-all", - Description: "Allow hub members to read all resources", - ScopeType: "hub", - ScopeID: "", - ResourceType: "*", - Actions: []string{"read", "list"}, - Effect: "allow", - }) + // 2. Narrow the hub-member-read-all policy from wildcard to explicit + // per-type read policies for directory/catalog resources. Project and agent + // reads now come from per-project membership-scoped policies (created by + // createProjectMembersGroupAndPolicy and backfillProjectMemberReadPolicies). + narrowHubMemberReadAll(ctx, s, group.ID) + + // Seed explicit per-type read policies for resources that should remain + // globally readable by all hub members. Only project and agent resources + // are excluded — those are gated by per-project membership policies. + for _, rt := range []string{"user", "group", "template", "harness_config", "broker", "runtime_broker", "gcp_service_account", "policy", "skill", "quota", "role", "role_binding", "hub"} { + seedPolicy(ctx, s, group.ID, &store.Policy{ + ID: api.NewUUID(), + Name: "hub-member-read-" + rt, + Description: "Allow hub members to read " + rt + " resources", + ScopeType: "hub", + ScopeID: "", + ResourceType: rt, + Actions: []string{"read", "list"}, + Effect: "allow", + }) + } // 3. Seed hub-member-create-projects policy seedPolicy(ctx, s, group.ID, &store.Policy{ @@ -79,6 +90,19 @@ func seedDefaultPoliciesAndGroups(ctx context.Context, s store.Store) { backfillSeededPolicyOrigin(ctx, s, []string{ "hub-member-read-all", "hub-member-create-projects", + "hub-member-read-user", + "hub-member-read-group", + "hub-member-read-template", + "hub-member-read-harness_config", + "hub-member-read-broker", + "hub-member-read-runtime_broker", + "hub-member-read-gcp_service_account", + "hub-member-read-policy", + "hub-member-read-skill", + "hub-member-read-quota", + "hub-member-read-role", + "hub-member-read-role_binding", + "hub-member-read-hub", }) // The human half of the svc-accnt service-account assign baseline is @@ -343,6 +367,132 @@ func backfillProjectMessageAction(ctx context.Context, s store.Store) { } } +// backfillProjectMemberReadPolicies ensures every existing project has +// per-project member-read policies for the "project" and "agent" resource +// types. Without this, narrowing hub-member-read-all at startup would lock +// regular (non-owner/non-admin) members of untouched projects out of +// project reads — they have no owner/admin bypass and the wildcard policy +// that previously granted access has been deleted. The inline backfill in +// createProjectMembersGroupAndPolicy covers projects that are touched after +// the upgrade; this covers the rest. +func backfillProjectMemberReadPolicies(ctx context.Context, s store.Store) { + var cursor string + for { + res, err := s.ListProjects(ctx, store.ProjectFilter{}, store.ListOptions{ + Limit: 500, + Cursor: cursor, + }) + if err != nil { + slog.Warn("failed to list projects for member-read-policy backfill", "error", err) + return + } + for i := range res.Items { + project := &res.Items[i] + group, err := s.GetGroupBySlug(ctx, "project:"+project.Slug+":members") + if err != nil { + slog.Debug("skipping member-read-policy backfill, no members group", + "project_id", project.ID, "slug", project.Slug) + continue + } + ensureProjectMemberReadPolicies(ctx, s, project, group.ID) + } + if res.NextCursor == "" { + break + } + cursor = res.NextCursor + } +} + +// ensureProjectMemberReadPolicies creates read+list policies for "project" +// and "agent" resource types scoped to the given project, bound to the +// project's members group. Idempotent: skips creation if the policy already +// exists, and repairs stale ScopeID on pre-existing policies. +// +// This is a standalone function (not a Server method) so it can be called +// from startup backfill paths that don't have a Server instance. +func ensureProjectMemberReadPolicies(ctx context.Context, s store.Store, project *store.Project, membersGroupID string) { + for _, rt := range []string{"project", "agent"} { + policyName := "project:" + project.Slug + ":member-read-" + rt + policy := &store.Policy{ + ID: api.NewUUID(), + Name: policyName, + Description: fmt.Sprintf("Allow members to read %s resources in project '%s'", rt, project.Slug), + ScopeType: "project", + ScopeID: project.ID, + ResourceType: rt, + Actions: []string{"read", "list"}, + Effect: "allow", + } + if err := s.CreatePolicy(ctx, policy); err != nil { + if !errors.Is(err, store.ErrAlreadyExists) { + slog.Warn("failed to create project member read policy", + "project_id", project.ID, "policy", policyName, "resourceType", rt, "error", err.Error()) + continue + } + // Policy already exists — ensure scope ID is correct. + existing, lookupErr := s.ListPolicies(ctx, store.PolicyFilter{Name: policyName}, store.ListOptions{Limit: 1}) + if lookupErr != nil || len(existing.Items) == 0 { + slog.Warn("failed to look up existing project member read policy", + "project_id", project.ID, "policy", policyName, "error", lookupErr) + continue + } + policy = &existing.Items[0] + if policy.ScopeID != project.ID { + policy.ScopeID = project.ID + if updateErr := s.UpdatePolicy(ctx, policy); updateErr != nil { + slog.Warn("failed to update existing project member read policy", + "project_id", project.ID, "policy", policyName, "error", updateErr.Error()) + } + } + } + + // Bind policy to the members group + if err := s.AddPolicyBinding(ctx, &store.PolicyBinding{ + PolicyID: policy.ID, + PrincipalType: "group", + PrincipalID: membersGroupID, + }); err != nil && !errors.Is(err, store.ErrAlreadyExists) { + slog.Warn("failed to bind project member read policy", + "project_id", project.ID, "policy", policyName, "error", err.Error()) + } + } +} + +// narrowHubMemberReadAll migrates an existing hub-member-read-all policy from +// the wildcard ResourceType:"*" to a no-op state. On existing hubs the wildcard +// policy grants every hub member read+list on all resource types, including +// project and agent. This migration deletes that wildcard policy so that +// project and agent reads are controlled by per-project membership policies +// instead. Per-type policies for all other resource types (user, group, +// template, harness_config, broker, runtime_broker, gcp_service_account, +// policy, skill, quota, role, role_binding, hub) are seeded separately. +// +// Idempotent: if the wildcard policy no longer exists, this is a no-op. +func narrowHubMemberReadAll(ctx context.Context, s store.Store, hubMembersGroupID string) { + existing, err := s.ListPolicies(ctx, store.PolicyFilter{ + Name: "hub-member-read-all", + ScopeType: "hub", + }, store.ListOptions{Limit: 1}) + if err != nil || len(existing.Items) == 0 { + return + } + policy := &existing.Items[0] + if policy.ResourceType != "*" { + return // already narrowed or customized + } + // Delete the wildcard policy. The per-type replacements are seeded right after. + if err := s.DeletePolicy(ctx, policy.ID); err != nil { + slog.Warn("failed to delete wildcard hub-member-read-all policy", + "id", policy.ID, "error", err) + return + } + // Record a tombstone so the old wildcard policy is not re-seeded. + _, _ = s.UpsertHubSetting(ctx, seedPolicyTombstoneKey("hub-member-read-all"), + json.RawMessage(`"narrowed"`), "system", -1, "seeded") + slog.Info("narrowed hub-member-read-all: deleted wildcard policy", + "id", policy.ID) +} + // seedPolicyTombstoneKey returns the hub-setting key used to record that a // seeded policy was intentionally deleted by an operator. func seedPolicyTombstoneKey(policyName string) string { diff --git a/pkg/hub/seed_assign_policy_test.go b/pkg/hub/seed_assign_policy_test.go index 3d6b4a243f..3e79c91709 100644 --- a/pkg/hub/seed_assign_policy_test.go +++ b/pkg/hub/seed_assign_policy_test.go @@ -40,10 +40,9 @@ func assignPolicyTestProject(t *testing.T, srv *Server, slug string) *store.Proj t.Helper() ctx := context.Background() p := &store.Project{ - ID: api.NewUUID(), - Name: slug + " project", - Slug: slug, - Visibility: "private", + ID: api.NewUUID(), + Name: slug + " project", + Slug: slug, } require.NoError(t, srv.store.CreateProject(ctx, p)) srv.createProjectMembersGroupAndPolicy(ctx, p) @@ -251,10 +250,9 @@ func TestBackfillProjectAssignPolicies_SkipsGrouplessProject(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "groupless project", - Slug: "assign-groupless", - Visibility: "private", + ID: api.NewUUID(), + Name: "groupless project", + Slug: "assign-groupless", } require.NoError(t, s.CreateProject(ctx, project)) diff --git a/pkg/hub/seed_tombstone_test.go b/pkg/hub/seed_tombstone_test.go index c47718a280..4c46a69b2f 100644 --- a/pkg/hub/seed_tombstone_test.go +++ b/pkg/hub/seed_tombstone_test.go @@ -34,7 +34,7 @@ func TestSeedPolicy_SeededPoliciesHaveOrigin(t *testing.T) { _, s := testServer(t) ctx := context.Background() - for _, name := range []string{"hub-member-read-all", "hub-member-create-projects"} { + for _, name := range []string{"hub-member-read-user", "hub-member-create-projects"} { res, err := s.ListPolicies(ctx, store.PolicyFilter{Name: name}, store.ListOptions{Limit: 1}) require.NoError(t, err) require.Len(t, res.Items, 1, "expected seeded policy %q to exist", name) @@ -49,7 +49,7 @@ func TestSeedPolicy_SkipsRecreationWhenTombstoneExists(t *testing.T) { _, s := testServer(t) ctx := context.Background() - const policyName = "hub-member-read-all" + const policyName = "hub-member-read-user" // Delete the seeded policy so we can test recreation. res, err := s.ListPolicies(ctx, store.PolicyFilter{Name: policyName}, store.ListOptions{Limit: 1}) @@ -70,9 +70,9 @@ func TestSeedPolicy_SkipsRecreationWhenTombstoneExists(t *testing.T) { seedPolicy(ctx, s, group.ID, &store.Policy{ ID: api.NewUUID(), Name: policyName, - Description: "Allow hub members to read all resources", + Description: "Allow hub members to read user resources", ScopeType: "hub", - ResourceType: "*", + ResourceType: "user", Actions: []string{"read", "list"}, Effect: "allow", }) @@ -89,7 +89,7 @@ func TestDeletePolicy_SeededPolicyCreatesTombstone(t *testing.T) { srv, s := testServer(t) ctx := context.Background() - const policyName = "hub-member-read-all" + const policyName = "hub-member-read-user" // Find the seeded policy. res, err := s.ListPolicies(ctx, store.PolicyFilter{Name: policyName}, store.ListOptions{Limit: 1}) @@ -180,16 +180,16 @@ func TestBackfillSeededPolicyOrigin_SkipsAlreadySet(t *testing.T) { // The seeded policies already have Origin set by the seeder. // Record the Updated timestamp before backfill. - res, err := s.ListPolicies(ctx, store.PolicyFilter{Name: "hub-member-read-all"}, store.ListOptions{Limit: 1}) + res, err := s.ListPolicies(ctx, store.PolicyFilter{Name: "hub-member-read-user"}, store.ListOptions{Limit: 1}) require.NoError(t, err) require.Len(t, res.Items, 1) updatedBefore := res.Items[0].Updated // Run backfill again. - backfillSeededPolicyOrigin(ctx, s, []string{"hub-member-read-all"}) + backfillSeededPolicyOrigin(ctx, s, []string{"hub-member-read-user"}) // Verify it was not updated (timestamp should be unchanged). - res, err = s.ListPolicies(ctx, store.PolicyFilter{Name: "hub-member-read-all"}, store.ListOptions{Limit: 1}) + res, err = s.ListPolicies(ctx, store.PolicyFilter{Name: "hub-member-read-user"}, store.ListOptions{Limit: 1}) require.NoError(t, err) require.Len(t, res.Items, 1) assert.Equal(t, updatedBefore, res.Items[0].Updated, diff --git a/pkg/hub/server.go b/pkg/hub/server.go index 03ff310507..e9175a00e9 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -1367,6 +1367,14 @@ func New(cfg ServerConfig, s store.Store) (*Server, error) { // causing non-owner/non-admin members to be denied agent messaging. backfillProjectMessageAction(ctx, s) + // Backfill per-project member-read policies for project and agent resource + // types. After narrowing hub-member-read-all, regular (non-owner/non-admin) + // members of existing projects that haven't been touched since the upgrade + // would be locked out without these policies. The inline path in + // createProjectMembersGroupAndPolicy covers active projects; this covers + // the rest. + backfillProjectMemberReadPolicies(ctx, s) + // Seed role definitions for the role-binding authorization model (Phase 1E). // Must run after seedDefaultPoliciesAndGroups so the hub-members group exists. seedRoleDefinitions(ctx, s) diff --git a/pkg/hub/stalled_detection_test.go b/pkg/hub/stalled_detection_test.go index 710f01cf8e..c8287f1927 100644 --- a/pkg/hub/stalled_detection_test.go +++ b/pkg/hub/stalled_detection_test.go @@ -58,10 +58,9 @@ func TestAgentStalledDetectionHandler_MarksStalledAgents(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "Stalled Detection Project", - Slug: "stalled-detect-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "Stalled Detection Project", + Slug: "stalled-detect-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -144,10 +143,9 @@ func TestAgentStalledDetectionHandler_ClearedByActivityEvent(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "Recovery Stalled Project", - Slug: "recovery-stalled-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "Recovery Stalled Project", + Slug: "recovery-stalled-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -205,10 +203,9 @@ func TestAgentStalledDetectionHandler_StalledFromActivityIsPreserved(t *testing. ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "Stalled Preserved Project", - Slug: "stalled-preserved-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "Stalled Preserved Project", + Slug: "stalled-preserved-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -286,10 +283,9 @@ func TestAgentStalledDetectionHandler_BlockedAgentNotStalled(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "Blocked Not Stalled Project", - Slug: "blocked-not-stalled-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "Blocked Not Stalled Project", + Slug: "blocked-not-stalled-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -350,10 +346,9 @@ func TestAgentStalledDetectionHandler_IdleAgentMarkedStalled(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "Idle Stalled Project", - Slug: "working-stalled-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "Idle Stalled Project", + Slug: "working-stalled-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -483,10 +478,9 @@ func TestAgentStalledDetectionHandler_AutoSuspendDisabled(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "AutoSuspend Disabled Project", - Slug: "autosuspend-disabled-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "AutoSuspend Disabled Project", + Slug: "autosuspend-disabled-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) @@ -550,10 +544,9 @@ func TestAgentStalledDetectionHandler_AutoSuspendEnabled(t *testing.T) { ctx := context.Background() project := &store.Project{ - ID: api.NewUUID(), - Name: "AutoSuspend Enabled Project", - Slug: "autosuspend-enabled-project", - Visibility: store.VisibilityPrivate, + ID: api.NewUUID(), + Name: "AutoSuspend Enabled Project", + Slug: "autosuspend-enabled-project", } if err := s.CreateProject(ctx, project); err != nil { t.Fatalf("failed to create project: %v", err) diff --git a/pkg/hub/validate_attributed_integration_test.go b/pkg/hub/validate_attributed_integration_test.go index 7d2dd96179..4f37cad459 100644 --- a/pkg/hub/validate_attributed_integration_test.go +++ b/pkg/hub/validate_attributed_integration_test.go @@ -57,10 +57,9 @@ func TestValidateAttributed_LegacyPath_ConversationIDStamped(t *testing.T) { agentSlug := "d4-agent" if err := s.CreateProject(ctx, &store.Project{ - ID: projectID, - Name: "d4-project", - Slug: "d4-project", - Visibility: store.VisibilityPrivate, + ID: projectID, + Name: "d4-project", + Slug: "d4-project", }); err != nil { t.Fatalf("CreateProject: %v", err) } diff --git a/pkg/hubclient/projects.go b/pkg/hubclient/projects.go index 387a973d5e..a9feeeef81 100644 --- a/pkg/hubclient/projects.go +++ b/pkg/hubclient/projects.go @@ -86,13 +86,12 @@ type projectService struct { // ListProjectsOptions configures project list filtering. type ListProjectsOptions struct { - Visibility string // Filter by visibility - GitRemote string // Filter by git remote (exact or prefix) - BrokerID string // Filter by contributing broker - Name string // Filter by exact name (case-insensitive) - Slug string // Filter by exact slug (case-insensitive) - Labels map[string]string - Page apiclient.PageOptions + GitRemote string // Filter by git remote (exact or prefix) + BrokerID string // Filter by contributing broker + Name string // Filter by exact name (case-insensitive) + Slug string // Filter by exact slug (case-insensitive) + Labels map[string]string + Page apiclient.PageOptions } // ListProjectsResponse is the response from listing projects. @@ -156,12 +155,11 @@ type ProjectMatch struct { // CreateProjectRequest is the request for creating a project without a broker. type CreateProjectRequest struct { - ID string `json:"id,omitempty"` - Slug string `json:"slug,omitempty"` - Name string `json:"name"` - GitRemote string `json:"gitRemote,omitempty"` - Visibility string `json:"visibility,omitempty"` - Labels map[string]string `json:"labels,omitempty"` + ID string `json:"id,omitempty"` + Slug string `json:"slug,omitempty"` + Name string `json:"name"` + GitRemote string `json:"gitRemote,omitempty"` + Labels map[string]string `json:"labels,omitempty"` } // UpdateProjectRequest is the request for updating a project. @@ -170,7 +168,6 @@ type UpdateProjectRequest struct { Slug string `json:"slug,omitempty"` Labels map[string]string `json:"labels,omitempty"` Annotations map[string]string `json:"annotations,omitempty"` - Visibility string `json:"visibility,omitempty"` DefaultRuntimeBrokerID string `json:"defaultRuntimeBrokerId,omitempty"` } @@ -214,9 +211,6 @@ type AddProviderResponse struct { func (s *projectService) List(ctx context.Context, opts *ListProjectsOptions) (*ListProjectsResponse, error) { query := url.Values{} if opts != nil { - if opts.Visibility != "" { - query.Set("visibility", opts.Visibility) - } if opts.GitRemote != "" { query.Set("gitRemote", opts.GitRemote) } diff --git a/pkg/hubclient/types.go b/pkg/hubclient/types.go index 8580973903..627bde1864 100644 --- a/pkg/hubclient/types.go +++ b/pkg/hubclient/types.go @@ -137,7 +137,6 @@ type Project struct { Updated time.Time `json:"updated"` CreatedBy string `json:"createdBy,omitempty"` OwnerID string `json:"ownerId,omitempty"` - Visibility string `json:"visibility,omitempty"` Labels map[string]string `json:"labels,omitempty"` Annotations map[string]string `json:"annotations,omitempty"` Providers []ProjectProvider `json:"providers,omitempty"` diff --git a/pkg/store/entadapter/broker_dispatch_store_test.go b/pkg/store/entadapter/broker_dispatch_store_test.go index 950f3c12eb..60c6484f8f 100644 --- a/pkg/store/entadapter/broker_dispatch_store_test.go +++ b/pkg/store/entadapter/broker_dispatch_store_test.go @@ -174,7 +174,7 @@ func TestListPendingMessages_ByBrokerAgent(t *testing.T) { brokerB := uuid.NewString() // A project and two agents, one per broker. - proj := &store.Project{ID: uuid.NewString(), Name: "p", Slug: "p-" + uuid.NewString()[:8], Visibility: store.VisibilityPrivate, OwnerID: uuid.NewString()} + proj := &store.Project{ID: uuid.NewString(), Name: "p", Slug: "p-" + uuid.NewString()[:8], OwnerID: uuid.NewString()} require.NoError(t, cs.CreateProject(ctx, proj)) projUID := uuid.MustParse(proj.ID) agentA := mustCreateAgent(t, client, projUID, brokerA) @@ -206,7 +206,7 @@ func TestCountStuckPendingMessages(t *testing.T) { proj := &store.Project{ ID: uuid.NewString(), Name: "p", Slug: "p-" + uuid.NewString()[:8], - Visibility: store.VisibilityPrivate, OwnerID: uuid.NewString(), + OwnerID: uuid.NewString(), } require.NoError(t, cs.CreateProject(ctx, proj)) @@ -246,7 +246,7 @@ func TestExpireStuckPendingMessages(t *testing.T) { proj := &store.Project{ ID: uuid.NewString(), Name: "p", Slug: "p-" + uuid.NewString()[:8], - Visibility: store.VisibilityPrivate, OwnerID: uuid.NewString(), + OwnerID: uuid.NewString(), } require.NoError(t, cs.CreateProject(ctx, proj)) diff --git a/pkg/store/entadapter/project_store.go b/pkg/store/entadapter/project_store.go index 028ead5eb2..7f71bc1e2d 100644 --- a/pkg/store/entadapter/project_store.go +++ b/pkg/store/entadapter/project_store.go @@ -108,7 +108,6 @@ func entProjectToStore(p *ent.Project) *store.Project { Updated: p.Updated, CreatedBy: p.CreatedBy, OwnerID: p.OwnerID, - Visibility: p.Visibility, } if p.GitRemote != nil { sp.GitRemote = *p.GitRemote @@ -153,9 +152,6 @@ func (s *ProjectStore) CreateProject(ctx context.Context, p *store.Project) erro SetCreatedBy(p.CreatedBy). SetOwnerID(p.OwnerID) - if p.Visibility != "" { - create.SetVisibility(p.Visibility) - } if p.GitRemote != "" { create.SetGitRemote(p.GitRemote) } @@ -191,9 +187,6 @@ func (s *ProjectStore) CreateProject(ctx context.Context, p *store.Project) erro p.Created = created.Created p.Updated = created.Updated - if p.Visibility == "" { - p.Visibility = created.Visibility - } return nil } @@ -295,8 +288,7 @@ func (s *ProjectStore) UpdateProject(ctx context.Context, p *store.Project) erro update := s.client.Project.UpdateOneID(uid). SetName(p.Name). SetSlug(p.Slug). - SetOwnerID(p.OwnerID). - SetVisibility(p.Visibility) + SetOwnerID(p.OwnerID) if p.GitRemote != "" { update.SetGitRemote(p.GitRemote) @@ -394,9 +386,6 @@ func (s *ProjectStore) ListProjects(ctx context.Context, filter store.ProjectFil if filter.ExcludeOwnerID != "" { query.Where(project.OwnerIDNEQ(filter.ExcludeOwnerID)) } - if filter.Visibility != "" { - query.Where(project.VisibilityEQ(filter.Visibility)) - } if filter.GitRemote != "" { query.Where(project.GitRemoteEQ(filter.GitRemote)) } else if filter.GitRemotePrefix != "" { diff --git a/pkg/store/entadapter/project_store_test.go b/pkg/store/entadapter/project_store_test.go index 48549bd5cd..d85ef49c59 100644 --- a/pkg/store/entadapter/project_store_test.go +++ b/pkg/store/entadapter/project_store_test.go @@ -39,11 +39,10 @@ func newTestProjectStore(t *testing.T) *ProjectStore { func newProject(seq int) *store.Project { id := uuid.NewString() return &store.Project{ - ID: id, - Name: "Project " + id[:8], - Slug: "project-" + id[:8], - Visibility: store.VisibilityPrivate, - Labels: map[string]string{"seq": id[:4]}, + ID: id, + Name: "Project " + id[:8], + Slug: "project-" + id[:8], + Labels: map[string]string{"seq": id[:4]}, } } @@ -64,7 +63,6 @@ func TestProject_CreateGet(t *testing.T) { assert.Equal(t, p.Name, got.Name) assert.Equal(t, p.Slug, got.Slug) assert.Equal(t, "https://github.com/acme/repo.git", got.GitRemote) - assert.Equal(t, store.VisibilityPrivate, got.Visibility) assert.Equal(t, store.ProjectTypeHubManaged, got.ProjectType) // computed default } @@ -210,24 +208,16 @@ func TestProject_ListFilters(t *testing.T) { owner := uuid.NewString() pub := newProject(1) - pub.Visibility = "public" pub.OwnerID = owner require.NoError(t, ps.CreateProject(ctx, pub)) priv := newProject(2) - priv.Visibility = store.VisibilityPrivate require.NoError(t, ps.CreateProject(ctx, priv)) all, err := ps.ListProjects(ctx, store.ProjectFilter{}, store.ListOptions{}) require.NoError(t, err) assert.Equal(t, 2, all.TotalCount) - byVis, err := ps.ListProjects(ctx, store.ProjectFilter{Visibility: "public"}, store.ListOptions{}) - require.NoError(t, err) - assert.Equal(t, 1, byVis.TotalCount) - require.Len(t, byVis.Items, 1) - assert.Equal(t, pub.ID, byVis.Items[0].ID) - byOwner, err := ps.ListProjects(ctx, store.ProjectFilter{OwnerID: owner}, store.ListOptions{}) require.NoError(t, err) assert.Equal(t, 1, byOwner.TotalCount) @@ -457,7 +447,7 @@ func TestProvider_UpsertAndGet(t *testing.T) { projectID := uuid.NewString() brokerID := uuid.NewString() - require.NoError(t, ps.CreateProject(ctx, &store.Project{ID: projectID, Name: "p", Slug: "p-" + projectID[:8], Visibility: store.VisibilityPrivate})) + require.NoError(t, ps.CreateProject(ctx, &store.Project{ID: projectID, Name: "p", Slug: "p-" + projectID[:8]})) prov := &store.ProjectProvider{ ProjectID: projectID, @@ -678,10 +668,9 @@ func TestListProjects_MaxLimit(t *testing.T) { const total = 1010 for i := 0; i < total; i++ { p := &store.Project{ - ID: uuid.NewString(), - Name: fmt.Sprintf("proj-%04d", i), - Slug: fmt.Sprintf("proj-%04d", i), - Visibility: store.VisibilityPrivate, + ID: uuid.NewString(), + Name: fmt.Sprintf("proj-%04d", i), + Slug: fmt.Sprintf("proj-%04d", i), } require.NoError(t, ps.CreateProject(ctx, p)) } diff --git a/pkg/store/models.go b/pkg/store/models.go index 15f6a43cbc..11b39bb2af 100644 --- a/pkg/store/models.go +++ b/pkg/store/models.go @@ -306,9 +306,8 @@ type Project struct { Updated time.Time `json:"updated"` // Ownership - CreatedBy string `json:"createdBy,omitempty"` - OwnerID string `json:"ownerId,omitempty"` - Visibility string `json:"visibility"` // private, team, public + CreatedBy string `json:"createdBy,omitempty"` + OwnerID string `json:"ownerId,omitempty"` // Configuration (stored as JSON) SharedDirs []api.SharedDir `json:"sharedDirs,omitempty"` @@ -2081,9 +2080,8 @@ func (g *Project) ToAPI() *api.ProjectInfo { Updated: g.Updated, // Ownership - CreatedBy: g.CreatedBy, - OwnerID: g.OwnerID, - Visibility: g.Visibility, + CreatedBy: g.CreatedBy, + OwnerID: g.OwnerID, // Metadata Labels: g.Labels, diff --git a/pkg/store/store.go b/pkg/store/store.go index 054a6e0070..96cd02fae9 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -381,7 +381,6 @@ type ProjectStore interface { // ProjectFilter defines criteria for filtering projects. type ProjectFilter struct { OwnerID string - Visibility string GitRemotePrefix string GitRemote string // Filter by exact git remote (case-sensitive) BrokerID string // Filter by contributing broker diff --git a/pkg/store/storetest/domains.go b/pkg/store/storetest/domains.go index 67dec05860..eddf67bad5 100644 --- a/pkg/store/storetest/domains.go +++ b/pkg/store/storetest/domains.go @@ -504,10 +504,9 @@ const agentDomainProjectID = "30000000-0000-0000-0000-0000000000d1" func seedAgentProject(t *testing.T, ctx context.Context, s store.Store) { t.Helper() require.NoError(t, s.CreateProject(ctx, &store.Project{ - ID: agentDomainProjectID, - Name: "agent-oracle-project", - Slug: "agent-oracle-" + agentDomainProjectID[:8], - Visibility: "private", + ID: agentDomainProjectID, + Name: "agent-oracle-project", + Slug: "agent-oracle-" + agentDomainProjectID[:8], })) } diff --git a/pkg/store/storetest/domains_project_broker.go b/pkg/store/storetest/domains_project_broker.go index 9af66c8dca..4060fe4f4b 100644 --- a/pkg/store/storetest/domains_project_broker.go +++ b/pkg/store/storetest/domains_project_broker.go @@ -33,11 +33,10 @@ func ProjectDomain() Domain[store.Project] { Make: func(seq int) *store.Project { id := uuid.NewString() return &store.Project{ - ID: id, - Name: fmt.Sprintf("Project %d", seq), - Slug: fmt.Sprintf("project-%d-%s", seq, id[:8]), - Visibility: store.VisibilityPrivate, - Labels: map[string]string{"seq": fmt.Sprintf("%d", seq)}, + ID: id, + Name: fmt.Sprintf("Project %d", seq), + Slug: fmt.Sprintf("project-%d-%s", seq, id[:8]), + Labels: map[string]string{"seq": fmt.Sprintf("%d", seq)}, } }, GetID: func(p *store.Project) string { return p.ID }, @@ -54,40 +53,20 @@ func ProjectDomain() Domain[store.Project] { assert.Equal(t, want.ID, got.ID) assert.Equal(t, want.Name, got.Name) assert.Equal(t, want.Slug, got.Slug) - assert.Equal(t, want.Visibility, got.Visibility) assert.False(t, got.Created.IsZero(), "Created timestamp should be set") }, Mutate: func(p *store.Project) { p.Name = "Renamed " + p.Name - p.Visibility = "public" }, Update: func(ctx context.Context, s store.Store, p *store.Project) error { return s.UpdateProject(ctx, p) }, VerifyMutated: func(t *testing.T, got *store.Project) { assert.Contains(t, got.Name, "Renamed ") - assert.Equal(t, "public", got.Visibility) }, Delete: func(ctx context.Context, s store.Store, id string) error { return s.DeleteProject(ctx, id) }, - Filters: []FilterCase[store.Project]{ - { - Name: "ByVisibility", - Seed: func(t *testing.T, ctx context.Context, s store.Store) { - require.NoError(t, s.CreateProject(ctx, &store.Project{ - ID: uuid.NewString(), Name: "Public", Slug: "public-" + uuid.NewString()[:8], Visibility: "public", - })) - require.NoError(t, s.CreateProject(ctx, &store.Project{ - ID: uuid.NewString(), Name: "Private", Slug: "private-" + uuid.NewString()[:8], Visibility: store.VisibilityPrivate, - })) - }, - List: func(ctx context.Context, s store.Store) (*store.ListResult[store.Project], error) { - return s.ListProjects(ctx, store.ProjectFilter{Visibility: "public"}, store.ListOptions{}) - }, - WantCount: 1, - }, - }, } } diff --git a/web/src/components/pages/onboarding.ts b/web/src/components/pages/onboarding.ts index 9a2700ca5a..d4914c0251 100644 --- a/web/src/components/pages/onboarding.ts +++ b/web/src/components/pages/onboarding.ts @@ -1517,7 +1517,7 @@ export class ScionPageOnboarding extends LitElement { const res = await apiFetch('/api/v1/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: this.wsProjectName.trim(), visibility: 'private' }), + body: JSON.stringify({ name: this.wsProjectName.trim() }), }); if (!res.ok) { this.error = await extractApiError(res, 'Failed to create project'); @@ -1675,7 +1675,7 @@ export class ScionPageOnboarding extends LitElement { const projRes = await apiFetch('/api/v1/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: this.wsProjectName.trim(), visibility: 'private' }), + body: JSON.stringify({ name: this.wsProjectName.trim() }), }); if (!projRes.ok) { this.error = await extractApiError(projRes, 'Failed to create project'); diff --git a/web/src/components/pages/project-create.ts b/web/src/components/pages/project-create.ts index 62dceceda8..41a9a1409e 100644 --- a/web/src/components/pages/project-create.ts +++ b/web/src/components/pages/project-create.ts @@ -71,9 +71,6 @@ export class ScionPageProjectCreate extends LitElement { @state() private branch = 'main'; - @state() - private visibility = 'private'; - @state() private mode: ProjectMode = 'hub'; @@ -582,7 +579,6 @@ export class ScionPageProjectCreate extends LitElement { const body: Record = { name: this.name.trim(), - visibility: this.visibility, }; if (this.slug.trim()) { @@ -984,25 +980,6 @@ export class ScionPageProjectCreate extends LitElement { ` : nothing} - ${this.mode !== 'template' - ? html` -
- - { - this.visibility = (e.target as HTMLElement & { value: string }).value; - }} - > - Private - Team - Public - -
- ` - : nothing} -
` : ''} diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 0fe46c2737..870845e7d1 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -152,7 +152,6 @@ export interface Project { gitRemote?: string; projectType?: ProjectType; status: ProjectStatus; - visibility?: string; labels?: Record; defaultRuntimeBrokerId?: string; ownerId?: string;