From f436b8838aa8afc71368704f47a9954edf0ec689 Mon Sep 17 00:00:00 2001 From: espdesign Date: Sun, 1 Nov 2026 08:54:45 -0500 Subject: [PATCH] feat(discord_bot): filter active project visibility and standardize permission toasts --- .../.openspec.yaml | 2 + .../design.md | 39 ++ .../proposal.md | 33 ++ .../project-visibility-authorization/spec.md | 62 +++ .../tasks.md | 20 + .../.openspec.yaml | 2 + .../design.md | 35 ++ .../proposal.md | 34 ++ .../project-visibility-authorization/spec.md | 26 ++ .../tasks.md | 12 + .../.openspec.yaml | 2 + .../design.md | 30 ++ .../proposal.md | 30 ++ .../project-visibility-authorization/spec.md | 24 ++ .../tasks.md | 14 + .../project-visibility-authorization/spec.md | 84 ++++ src/adapters/discord_bot/cogs/autocomplete.py | 3 + src/adapters/discord_bot/cogs/project_cog.py | 11 + src/adapters/discord_bot/views/hub_menu.py | 77 ++-- .../discord_bot/views/project/dashboard.py | 46 ++- .../discord_bot/views/project/roster.py | 5 +- src/adapters/discord_bot/views/tree_view.py | 31 +- src/services/auth_service.py | 29 +- tests/test_authorization.py | 377 +++++++++++++++++- tests/test_menus.py | 3 +- 25 files changed, 979 insertions(+), 52 deletions(-) create mode 100644 openspec/changes/archive/2026-11-01-filter-project-visibility/.openspec.yaml create mode 100644 openspec/changes/archive/2026-11-01-filter-project-visibility/design.md create mode 100644 openspec/changes/archive/2026-11-01-filter-project-visibility/proposal.md create mode 100644 openspec/changes/archive/2026-11-01-filter-project-visibility/specs/project-visibility-authorization/spec.md create mode 100644 openspec/changes/archive/2026-11-01-filter-project-visibility/tasks.md create mode 100644 openspec/changes/archive/2026-11-01-standardize-permission-toasts/.openspec.yaml create mode 100644 openspec/changes/archive/2026-11-01-standardize-permission-toasts/design.md create mode 100644 openspec/changes/archive/2026-11-01-standardize-permission-toasts/proposal.md create mode 100644 openspec/changes/archive/2026-11-01-standardize-permission-toasts/specs/project-visibility-authorization/spec.md create mode 100644 openspec/changes/archive/2026-11-01-standardize-permission-toasts/tasks.md create mode 100644 openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/.openspec.yaml create mode 100644 openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/design.md create mode 100644 openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/proposal.md create mode 100644 openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/specs/project-visibility-authorization/spec.md create mode 100644 openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/tasks.md create mode 100644 openspec/specs/project-visibility-authorization/spec.md diff --git a/openspec/changes/archive/2026-11-01-filter-project-visibility/.openspec.yaml b/openspec/changes/archive/2026-11-01-filter-project-visibility/.openspec.yaml new file mode 100644 index 0000000..25002a8 --- /dev/null +++ b/openspec/changes/archive/2026-11-01-filter-project-visibility/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-01 diff --git a/openspec/changes/archive/2026-11-01-filter-project-visibility/design.md b/openspec/changes/archive/2026-11-01-filter-project-visibility/design.md new file mode 100644 index 0000000..ad7043c --- /dev/null +++ b/openspec/changes/archive/2026-11-01-filter-project-visibility/design.md @@ -0,0 +1,39 @@ +# Design + +## Context + +See `proposal.md` for motivation. Currently, `AuthService.can_view_project(user, project_id)` exists and correctly enforces access controls for tech tree visualization. However, project listing in `ProjectMenuView._on_list_projects_clicked`, `ProjectCommandsMixin.project_list`, and `AutocompleteMixin.project_autocomplete` calls `project_service.list_projects()` directly without filtering against user permissions. + +## Goals / Non-Goals + +**Goals:** +- Provide a high-performance batch authorization check `filter_visible_projects(user, projects)` in `AuthService`. +- Filter project listing in `ProjectMenuView` (active projects roster) and `/pm project list`. +- Filter project name suggestions in `AutocompleteMixin.project_autocomplete`. +- Provide clear and actionable ephemeral feedback when an authenticated user has zero visible projects. + +**Non-Goals:** +- Altering the pinned forum control hub embed (`build_hub_welcome_embed`), which is channel-scoped and public to anyone with channel access. +- Restricting `can_manage_projects` administrative dashboard views (`/pm dashboard`). + +## Decisions + +### Decision 1: Batch Filter Helper on `AuthService` +- **Choice**: Implement `filter_visible_projects(user: ActorPrincipal | Any, projects: list[Project]) -> list[Project]` on `AuthService`, and update `can_view_project` to accept `Project | UUID`. +- **Rationale**: Calling `can_view_project(user, p.id)` in a loop incurs an N+1 query penalty (calling `get_by_id` and `list_squads_for_project` for every project). A batch helper can short-circuit immediately for server managers (0 DB queries) and reuse already-fetched `Project` objects. +- **Alternatives Considered**: + - *Looping `can_view_project` at the call site*: Causes 2N queries for N projects. + - *Filtering in SQL queries*: Requires passing caller role IDs down into repository layer, coupling DB layer with Discord caller security context and violating hexagonal separation. + +### Decision 2: Triggering Visibility Check in Hub on "Active Projects" Click +- **Choice**: Allow regular members to open `ProjectMenuView` (where they see `Active Projects`, `Visual Graph`, and `PM Main Menu`), and perform the project visibility evaluation when they click `Active Projects`. +- **Rationale**: Keeps the control hub button interaction fast and responsive without performing auth checks on the initial hub button click. If the user clicks `Active Projects` and has 0 accessible projects, an informative ephemeral message is displayed. + +### Decision 3: Preserving `auth_service` across Navigation +- **Choice**: Pass `auth_service` through `ProjectMenuView` into `ProjectActiveListView`, and back to `build_project_menu` when the user clicks "Back to Project Menu". +- **Rationale**: Prevents dropping the authorization context during view navigation transitions. + +## Risks / Trade-offs + +- **[Performance on Large Project Counts]** → Mitigated by batch evaluation in `filter_visible_projects` and instant server manager bypass. +- **[Ephemeral State on Back Navigation]** → Mitigated by passing `auth_service` through `ProjectActiveListView`. diff --git a/openspec/changes/archive/2026-11-01-filter-project-visibility/proposal.md b/openspec/changes/archive/2026-11-01-filter-project-visibility/proposal.md new file mode 100644 index 0000000..fbfa9ee --- /dev/null +++ b/openspec/changes/archive/2026-11-01-filter-project-visibility/proposal.md @@ -0,0 +1,33 @@ +# Proposal + +## Why + +Currently, when users open the Active Projects roster (via the Projects Hub button on the forum control hub) or execute `/pm project list`, all active projects across the Discord server are displayed regardless of user roles or squad assignments. Additionally, project autocomplete suggestions leak private project names across teams. Projects restricted to dedicated squads must be hidden from unauthorized members to prevent cross-team information leaks and maintain confidential project boundaries. + +## What Changes + +- Scope active project listing in the **Project Management Hub** (`ProjectMenuView._on_list_projects_clicked`) so that only projects the caller has permission to view are rendered in the `ProjectActiveListView`. +- Provide clear, graceful ephemeral feedback when a user has permission to view zero active projects in the server. +- Scope `/pm project list` slash command output so it only includes projects the user is authorized to view. +- Scope `project_autocomplete` across `/pm` slash commands so users only receive autocomplete suggestions for projects they have permission to view. +- Enhance `AuthService` with a high-performance batch filtering method (`filter_visible_projects`) to prevent N+1 database queries when checking permissions for project lists. + +## Capabilities + +### New Capabilities +- `project-visibility-authorization`: Enforces role-based visibility scoping for active projects across interactive UI rosters, slash commands, and autocompletion. + +### Modified Capabilities + + +## Impact + +- **Adapters**: + - `src/adapters/discord_bot/views/project/dashboard.py` (`ProjectMenuView`) + - `src/adapters/discord_bot/views/project/roster.py` (`ProjectActiveListView`) + - `src/adapters/discord_bot/cogs/project_cog.py` (`ProjectCommandsMixin.project_list`) + - `src/adapters/discord_bot/cogs/autocomplete.py` (`AutocompleteMixin.project_autocomplete`) +- **Services**: + - `src/services/auth_service.py` (`AuthService`) +- **Tests**: + - `tests/test_authorization.py` diff --git a/openspec/changes/archive/2026-11-01-filter-project-visibility/specs/project-visibility-authorization/spec.md b/openspec/changes/archive/2026-11-01-filter-project-visibility/specs/project-visibility-authorization/spec.md new file mode 100644 index 0000000..943eebd --- /dev/null +++ b/openspec/changes/archive/2026-11-01-filter-project-visibility/specs/project-visibility-authorization/spec.md @@ -0,0 +1,62 @@ +# Spec Delta + +## Purpose + +Enforces role-based visibility scoping for active projects across interactive UI rosters, slash commands, and autocompletion to prevent private projects from leaking to unauthorized members. + +## ADDED Requirements + +### Requirement: Filter active projects in roster view +The system SHALL filter active projects displayed in the Project Active List roster view so that members only see projects they are authorized to view based on squad roles, project lead assignment, or server manager privileges. + +#### Scenario: Squad member opens active projects roster +- **GIVEN** a project is restricted to a squad Discord role +- **WHEN** a member holding that squad role clicks "Active Projects" in the Project Management Hub +- **THEN** the restricted project is visible in the active projects roster + +#### Scenario: Outsider opens active projects roster +- **GIVEN** a project is restricted to a squad Discord role +- **WHEN** a member who does not hold the squad role and is not the project lead clicks "Active Projects" +- **THEN** the restricted project is omitted from the active projects roster + +### Requirement: Zero accessible projects feedback +When a member has access to zero active projects in the server, the system SHALL display an informative ephemeral notice explaining that they do not have permission to view any active projects. + +#### Scenario: Member with no viewable projects clicks Active Projects +- **GIVEN** there are active projects in the server but the member is unauthorized for all of them +- **WHEN** the member clicks "Active Projects" in the Project Management Hub +- **THEN** the system sends an ephemeral message explaining they lack permission to view any active projects + +### Requirement: Filter active projects in slash command list +The system SHALL filter the projects returned by the `/pm project list` slash command so that only projects the caller is authorized to view are included in the embed. + +#### Scenario: Running project list with mixed visibility +- **GIVEN** multiple active projects exist, with some public and some restricted to other squads +- **WHEN** a member executes `/pm project list` +- **THEN** the response embed only includes public projects and projects matching the caller's squad or lead status + +#### Scenario: Running project list with zero accessible projects +- **GIVEN** all active projects in the server are restricted to squads the caller does not belong to +- **WHEN** the caller executes `/pm project list` +- **THEN** the command responds with an informative notice indicating no accessible projects were found + +### Requirement: Filter project autocomplete suggestions +The system SHALL filter autocomplete suggestions for project name parameters across all slash commands so that private projects are never suggested to unauthorized members. + +#### Scenario: Autocomplete invoked by squad member +- **GIVEN** a private project exists for Squad A +- **WHEN** a member of Squad A types in the `project_name` parameter of `/pm task list` +- **THEN** the private project is included in the suggested autocomplete choices + +#### Scenario: Autocomplete invoked by non-member +- **GIVEN** a private project exists for Squad A +- **WHEN** a non-member types in the `project_name` parameter of `/pm task list` +- **THEN** the private project is excluded from the suggested autocomplete choices + +### Requirement: Server manager visibility bypass +The system SHALL allow Discord server managers with administrator or manage server permissions to view and autocomplete all active projects regardless of squad assignment. + +#### Scenario: Server manager views active projects +- **GIVEN** private projects exist across multiple different squads +- **WHEN** a server manager opens the active projects roster or runs `/pm project list` +- **THEN** all active projects across all squads are displayed diff --git a/openspec/changes/archive/2026-11-01-filter-project-visibility/tasks.md b/openspec/changes/archive/2026-11-01-filter-project-visibility/tasks.md new file mode 100644 index 0000000..0ae0bf8 --- /dev/null +++ b/openspec/changes/archive/2026-11-01-filter-project-visibility/tasks.md @@ -0,0 +1,20 @@ +# Tasks + +## 1. Service Layer Batch Filtering + +- [x] 1.1 Implement `filter_visible_projects(user, projects)` and support `Project | UUID` in `can_view_project` on `AuthService`. Verify with unit tests in `tests/test_authorization.py`. +- [x] 1.2 Add unit test coverage in `tests/test_authorization.py` for server manager bypass, project lead access, squad role membership, and public projects. + +## 2. Interactive Roster & Control Hub Flow + +- [x] 2.1 Update `ProjectMenuView._on_list_projects_clicked` in `src/adapters/discord_bot/views/project/dashboard.py` to filter projects with `auth_service.filter_visible_projects` and send an ephemeral error toast when 0 projects are accessible. Verify with mock interaction test in `tests/test_authorization.py`. +- [x] 2.2 Ensure `auth_service` is passed into `ProjectActiveListView` and propagated back to `build_project_menu` upon "Back to Project Menu" click in `src/adapters/discord_bot/views/project/roster.py`. + +## 3. Slash Command & Autocomplete Scoping + +- [x] 3.1 Update `ProjectCommandsMixin.project_list` in `src/adapters/discord_bot/cogs/project_cog.py` to filter active projects and handle the empty accessible projects state. Verify with test in `tests/test_authorization.py`. +- [x] 3.2 Update `AutocompleteMixin.project_autocomplete` in `src/adapters/discord_bot/cogs/autocomplete.py` to filter project suggestions using `auth_service.filter_visible_projects`. Verify with autocomplete test in `tests/test_authorization.py`. + +## 4. Verification & Architectural Fitness + +- [x] 4.1 Run `make check` (`ruff check .`, `ruff format --check .`, and `pytest tests/`) to ensure no regressions or architecture boundary violations. diff --git a/openspec/changes/archive/2026-11-01-standardize-permission-toasts/.openspec.yaml b/openspec/changes/archive/2026-11-01-standardize-permission-toasts/.openspec.yaml new file mode 100644 index 0000000..25002a8 --- /dev/null +++ b/openspec/changes/archive/2026-11-01-standardize-permission-toasts/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-01 diff --git a/openspec/changes/archive/2026-11-01-standardize-permission-toasts/design.md b/openspec/changes/archive/2026-11-01-standardize-permission-toasts/design.md new file mode 100644 index 0000000..f984b9c --- /dev/null +++ b/openspec/changes/archive/2026-11-01-standardize-permission-toasts/design.md @@ -0,0 +1,35 @@ +# Design: Standardized Permission Toasts + +## Context +See `proposal.md` for motivation. PR #108 created `send_toast`, `build_toast_embed`, and `attach_dismissal_notice` to unify ephemeral notifications into color-coded embeds with countdown timers. However, earlier permission checks in forum hubs, tree views, and project menus remained implemented with raw `interaction.response.send_message` and hardcoded status emojis. + +## Goals / Non-Goals + +**Goals:** +- Unify all visual graph, project creation, and project visibility denial notices to use `send_toast(interaction, message, toast_type=ToastType.ERROR)`. +- Apply Discord blockquote styling (`> `) to secondary requirement/guidance text across all permission error toasts. +- Eliminate raw emoji clutter (`❌`) in favor of standard red embed borders. +- Automatically present the dynamic countdown timer (`*Auto-dismisses *`) via `attach_dismissal_notice`. + +**Non-Goals:** +- Modifying underlying authorization rules or `AuthService` evaluation logic. +- Altering ephemeral menu lifetimes or non-toast views. + +## Decisions + +### Decision 1: Use `send_toast` over manual `build_toast_embed` + `schedule_toast_dismissal` +- **Choice**: Call `await send_toast(interaction, message, toast_type=ToastType.ERROR)` directly. +- **Rationale**: `send_toast` automatically handles interaction state (unresponded vs deferred), builds the embed with countdown footer, sends with `ephemeral=True`, and schedules background toast dismissal via `menu_manager`. +- **Alternatives considered**: Manually building the embed with `build_toast_embed` and calling `schedule_toast_dismissal`. Rejected because it duplicates response dispatch logic. + +### Decision 2: Consistent Blockquote Formatting for Secondary Requirements +- **Choice**: Structure multi-line permission notices as: + ```python + f"You do not have permission to view the visual graph for **{target_project.name}**.\n" + "> You must hold the project squad's Discord role, be the Project Lead, or be a server manager." + ``` +- **Rationale**: Keeps the primary refusal bold and clear on line 1, while indenting the prerequisites/next actions as a readable contextual blockquote on line 2. + +## Risks / Trade-offs + +- **[Existing unit tests assert raw text strings on `inter.response.send_message`]** → Update `tests/test_authorization.py` to assert the embed description on `inter.response.send_message` (or mock `send_toast`). diff --git a/openspec/changes/archive/2026-11-01-standardize-permission-toasts/proposal.md b/openspec/changes/archive/2026-11-01-standardize-permission-toasts/proposal.md new file mode 100644 index 0000000..70120f8 --- /dev/null +++ b/openspec/changes/archive/2026-11-01-standardize-permission-toasts/proposal.md @@ -0,0 +1,34 @@ +# Proposal: Standardize Permission Toast Presentation and Lifecycle + +## Why + +Permission denial messages across the Forum Control Hub, Project Menu, and Visual Graph Viewer currently send unstyled raw markdown text via `interaction.response.send_message` with hardcoded `❌` emojis. While auto-dismissal was recently scheduled in the background, users do not see the dynamic Discord relative countdown timer (`*Auto-dismisses *`), lack blockquote formatting (`> `) for secondary guidance, and miss the semantic color-coded embed cards established in PR #108 / ADR 0007. + +Standardizing these notices to use `send_toast` with `ToastType.ERROR` and blockquote styling eliminates interface drift and provides clear, consistent, and polished feedback. + +## What Changes + +- **Standardize Visual Graph & Hub Permission Messages**: + - Replace raw `interaction.response.send_message` calls with `send_toast(interaction, ..., toast_type=ToastType.ERROR)`. + - Format multi-line permission notices using blockquote styling (`> `) for secondary requirement explanations. + - Remove redundant `❌` leading emojis, allowing the red embed status color bar to indicate the error state cleanly. + - Ensure the dynamic Discord relative countdown timer (`*Auto-dismisses *`) is automatically attached to the embed description. +- **Affected Views & Handlers**: + - `PmHubView.tech_tree_tab`: Single project permission denial and zero accessible projects feedback. + - `PmHubView.create_project_btn`: Project creation permission denial. + - `ProjectMenuView._on_tech_tree_clicked`: Zero accessible visual graphs notice. + - `ProjectMenuView._on_list_projects_clicked`: Zero accessible active projects notice. + - `TechTreeViewer._rerender`: Visual graph orientation switch permission denial. + - `TechTreeProjectSelectView._on_select_project`: Project selection permission denial in visual graph picker. + +## Capabilities + +### Modified Capabilities +- `project-visibility-authorization`: Update requirement `Ephemeral toast auto-dismissal for visual graph and hub permission feedback` to mandate standardized error toast embeds (`ToastType.ERROR`), dynamic relative countdown timers, and blockquote styling for secondary requirement explanations. + +## Impact + +- `src/adapters/discord_bot/views/hub_menu.py`: Refactored to use `send_toast` for permission notices. +- `src/adapters/discord_bot/views/project/dashboard.py`: Refactored to use `send_toast` for permission notices. +- `src/adapters/discord_bot/views/tree_view.py`: Refactored to use `send_toast` for permission notices. +- `tests/test_authorization.py`: Updated test assertions to inspect embed descriptions, blockquote formatting, and toast structures. diff --git a/openspec/changes/archive/2026-11-01-standardize-permission-toasts/specs/project-visibility-authorization/spec.md b/openspec/changes/archive/2026-11-01-standardize-permission-toasts/specs/project-visibility-authorization/spec.md new file mode 100644 index 0000000..99ea364 --- /dev/null +++ b/openspec/changes/archive/2026-11-01-standardize-permission-toasts/specs/project-visibility-authorization/spec.md @@ -0,0 +1,26 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Ephemeral toast auto-dismissal for visual graph and hub permission feedback +The system SHALL format permission-denied notices triggered when attempting to view restricted visual graphs or execute unauthorized project actions as standardized ephemeral error toast embeds (`ToastType.ERROR`) with blockquote formatting for secondary requirement explanations, stripped leading status emojis, and dynamic relative auto-dismiss countdown timers (`*Auto-dismisses *`). + +#### Scenario: Visual graph permission denial auto-dismissal on hub +- **GIVEN** a member is unauthorized to view the visual graph for a project +- **WHEN** the member clicks "Visual Graph" on the Forum Control Hub +- **THEN** the system responds with an ephemeral error toast embed (`ToastType.ERROR`) +- **AND** the embed contains the target project name with secondary role requirements formatted as a blockquote +- **AND** the embed description includes an auto-dismiss relative countdown timer scheduled for standard toast duration + +#### Scenario: Visual graph zero accessible projects auto-dismissal +- **GIVEN** a member has access to zero project visual graphs +- **WHEN** the member clicks "Visual Graph" in the Project Management Hub or on the Forum Control Hub +- **THEN** the system responds with an ephemeral error toast embed (`ToastType.ERROR`) +- **AND** the notice formats secondary requirement guidance as a blockquote +- **AND** the embed description includes an auto-dismiss relative countdown timer scheduled for standard toast duration + +#### Scenario: Visual graph viewer project switch permission denial +- **GIVEN** a member is viewing the interactive visual graph viewer +- **WHEN** the member selects a restricted project in the project dropdown +- **THEN** the system responds with an ephemeral error toast embed (`ToastType.ERROR`) +- **AND** the embed description includes an auto-dismiss relative countdown timer scheduled for standard toast duration diff --git a/openspec/changes/archive/2026-11-01-standardize-permission-toasts/tasks.md b/openspec/changes/archive/2026-11-01-standardize-permission-toasts/tasks.md new file mode 100644 index 0000000..42ac211 --- /dev/null +++ b/openspec/changes/archive/2026-11-01-standardize-permission-toasts/tasks.md @@ -0,0 +1,12 @@ +# Tasks + +## 1. Views Refactoring + +- [x] 1.1 Convert `PmHubView` permission notices (`tech_tree_tab`, `create_project_btn`, `new_task_btn`) to use `send_toast` with `ToastType.ERROR` and blockquote formatting, and verify with unit tests. +- [x] 1.2 Convert `ProjectMenuView` permission notices (`_on_tech_tree_clicked`, `_on_list_projects_clicked`) to use `send_toast` with `ToastType.ERROR` and blockquote formatting, and verify with unit tests. +- [x] 1.3 Convert `TechTreeViewer` and `TechTreeProjectSelectView` permission notices (`_rerender`, `_on_select_project`) to use `send_toast` with `ToastType.ERROR` and blockquote formatting, and verify with unit tests. + +## 2. Test Updates & Verification + +- [x] 2.1 Update `tests/test_authorization.py` to assert that permission denial responses produce red toast embeds containing blockquote guidance and countdown timers. Verify with `direnv exec . uv run pytest tests/test_authorization.py`. +- [x] 2.2 Run full lint, format, and test checks (`direnv exec . uv run ruff check . && direnv exec . uv run ruff format --check . && direnv exec . uv run pytest tests/`). diff --git a/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/.openspec.yaml b/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/.openspec.yaml new file mode 100644 index 0000000..25002a8 --- /dev/null +++ b/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-01 diff --git a/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/design.md b/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/design.md new file mode 100644 index 0000000..9b17930 --- /dev/null +++ b/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/design.md @@ -0,0 +1,30 @@ +# Design + +## Context + +See `proposal.md` for motivation. In `PmHubView`, `ProjectMenuView`, and `TreeView`, permission checks send ephemeral messages via `interaction.response.send_message(..., ephemeral=True)`. However, unlike task and project mutating views, these visual graph and hub entrypoints do not call `menu_manager.schedule_toast_dismissal(interaction, delay=TOAST_DURATION_STANDARD)`. + +## Goals / Non-Goals + +**Goals:** +- Ensure all visual graph and hub action permission denial alerts auto-dismiss after `TOAST_DURATION_STANDARD` (6.0s). +- Maintain consistency across `PmHubView`, `ProjectMenuView`, and `TreeView`. + +**Non-Goals:** +- Refactoring the entire codebase's alert system from raw `send_message` to `send_toast` embeds; this is an ad-hoc fix targeting visual graph and hub permission feedback. + +## Decisions + +### Decision 1: Use `menu_manager.schedule_toast_dismissal` on Ephemeral Message Responses +- **Choice**: Call `menu_manager.schedule_toast_dismissal(interaction, delay=TOAST_DURATION_STANDARD)` immediately following each `interaction.response.send_message(..., ephemeral=True)` in: + 1. `PmHubView.tree_tab` (single-project check) + 2. `PmHubView.tree_tab` (multi-project zero-accessible check) + 3. `PmHubView.create_project_btn` (create project permission check) + 4. `ProjectMenuView._on_tech_tree_clicked` (zero-accessible check) + 5. `TreeView._on_change_project` (project switch check) + 6. `TreeView._on_toggle_layout` (layout orientation toggle check) +- **Rationale**: This is the established, battle-tested pattern used in other interactive menus in `src/adapters/discord_bot/views/` (e.g. `_on_list_projects_clicked`, `new_task_btn`). + +## Risks / Trade-offs + +- **[Interaction Expiration]** → `schedule_toast_dismissal` catches Discord `NotFound` and `Forbidden` errors internally, safely handling expired or dismissed interactions. diff --git a/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/proposal.md b/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/proposal.md new file mode 100644 index 0000000..8af6458 --- /dev/null +++ b/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/proposal.md @@ -0,0 +1,30 @@ +# Proposal + +## Why + +When an unauthorized user attempts to view a project's visual tech tree graph or clicks restricted project management actions on the Forum Control Hub, the error message is sent ephemerally without auto-dismissal. This leaves an error message stuck on the user's screen indefinitely until dismissed manually, inconsistent with other self-dismissing ephemeral feedback across the bot. + +## What Changes + +- Schedule auto-dismissal via `menu_manager.schedule_toast_dismissal` for the single-project visual graph permission error in `PmHubView.tree_tab`. +- Schedule auto-dismissal for the multi-project zero-accessible visual graph permission notice in `PmHubView.tree_tab`. +- Schedule auto-dismissal for the create-project permission denial in `PmHubView.create_project_btn`. +- Schedule auto-dismissal for the visual graph permission notice in `ProjectMenuView._on_tech_tree_clicked`. +- Schedule auto-dismissal for visual graph project selection and layout toggle permission denials in `TreeView`. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `project-visibility-authorization`: Require standard ephemeral toast auto-dismissal for all visual graph and hub permission-denied alerts. + +## Impact + +- **Adapters**: + - `src/adapters/discord_bot/views/hub_menu.py` (`PmHubView`) + - `src/adapters/discord_bot/views/project/dashboard.py` (`ProjectMenuView`) + - `src/adapters/discord_bot/views/tree_view.py` (`TreeView`, `TechTreeProjectSelectView`) +- **Tests**: + - `tests/test_authorization.py` diff --git a/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/specs/project-visibility-authorization/spec.md b/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/specs/project-visibility-authorization/spec.md new file mode 100644 index 0000000..6daed88 --- /dev/null +++ b/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/specs/project-visibility-authorization/spec.md @@ -0,0 +1,24 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Ephemeral toast auto-dismissal for visual graph and hub permission feedback +The system SHALL schedule auto-dismissal after standard toast duration for ephemeral permission-denied notices triggered when attempting to view restricted visual graphs or execute unauthorized project actions. + +#### Scenario: Visual graph permission denial auto-dismissal on hub +- **GIVEN** a member is unauthorized to view the visual graph for a project +- **WHEN** the member clicks "Visual Graph" on the Forum Control Hub +- **THEN** the system sends the permission denied message ephemerally +- **AND** the system schedules auto-dismissal of the ephemeral message after standard toast duration + +#### Scenario: Visual graph zero accessible projects auto-dismissal +- **GIVEN** a member has access to zero project visual graphs +- **WHEN** the member clicks "Visual Graph" in the Project Management Hub or on the Forum Control Hub +- **THEN** the system sends the notice ephemerally +- **AND** the system schedules auto-dismissal of the ephemeral message after standard toast duration + +#### Scenario: Visual graph viewer project switch permission denial +- **GIVEN** a member is viewing the interactive visual graph viewer +- **WHEN** the member selects a restricted project in the project dropdown +- **THEN** the system sends the permission denied notice ephemerally +- **AND** the system schedules auto-dismissal of the notice after standard toast duration diff --git a/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/tasks.md b/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/tasks.md new file mode 100644 index 0000000..849639d --- /dev/null +++ b/openspec/changes/archive/2026-11-01-visual-graph-permission-toast-dismissal/tasks.md @@ -0,0 +1,14 @@ +# Tasks + +## 1. Control Hub & Dashboard Permission Dismissals + +- [x] 1.1 In `src/adapters/discord_bot/views/hub_menu.py`, schedule toast dismissal using `menu_manager.schedule_toast_dismissal` for single-project and multi-project visual graph permission denials and create project denial. Verify with tests in `tests/test_authorization.py`. +- [x] 1.2 In `src/adapters/discord_bot/views/project/dashboard.py`, schedule toast dismissal for visual graph zero-accessible project denial in `_on_tech_tree_clicked`. Verify with test in `tests/test_authorization.py`. + +## 2. Tech Tree Interactive Viewer Dismissals + +- [x] 2.1 In `src/adapters/discord_bot/views/tree_view.py`, schedule toast dismissal for project switch and layout toggle permission denials. Verify with test in `tests/test_authorization.py`. + +## 3. Verification & Architectural Fitness + +- [x] 3.1 Run `make check` (`ruff check .`, `ruff format --check .`, and `pytest tests/`) to verify all checks pass. diff --git a/openspec/specs/project-visibility-authorization/spec.md b/openspec/specs/project-visibility-authorization/spec.md new file mode 100644 index 0000000..2d12c1b --- /dev/null +++ b/openspec/specs/project-visibility-authorization/spec.md @@ -0,0 +1,84 @@ +# project-visibility-authorization Specification + +## Purpose +Enforces role-based visibility scoping for active projects across interactive UI rosters, slash commands, and autocompletion to prevent private projects from leaking to unauthorized members. + +## Requirements + +### Requirement: Filter active projects in roster view +The system SHALL filter active projects displayed in the Project Active List roster view so that members only see projects they are authorized to view based on squad roles, project lead assignment, or server manager privileges. + +#### Scenario: Squad member opens active projects roster +- **GIVEN** a project is restricted to a squad Discord role +- **WHEN** a member holding that squad role clicks "Active Projects" in the Project Management Hub +- **THEN** the restricted project is visible in the active projects roster + +#### Scenario: Outsider opens active projects roster +- **GIVEN** a project is restricted to a squad Discord role +- **WHEN** a member who does not hold the squad role and is not the project lead clicks "Active Projects" +- **THEN** the restricted project is omitted from the active projects roster + +### Requirement: Zero accessible projects feedback +When a member has access to zero active projects in the server, the system SHALL display an informative ephemeral notice explaining that they do not have permission to view any active projects. + +#### Scenario: Member with no viewable projects clicks Active Projects +- **GIVEN** there are active projects in the server but the member is unauthorized for all of them +- **WHEN** the member clicks "Active Projects" in the Project Management Hub +- **THEN** the system sends an ephemeral message explaining they lack permission to view any active projects + +### Requirement: Filter active projects in slash command list +The system SHALL filter the projects returned by the `/pm project list` slash command so that only projects the caller is authorized to view are included in the embed. + +#### Scenario: Running project list with mixed visibility +- **GIVEN** multiple active projects exist, with some public and some restricted to other squads +- **WHEN** a member executes `/pm project list` +- **THEN** the response embed only includes public projects and projects matching the caller's squad or lead status + +#### Scenario: Running project list with zero accessible projects +- **GIVEN** all active projects in the server are restricted to squads the caller does not belong to +- **WHEN** the caller executes `/pm project list` +- **THEN** the command responds with an informative notice indicating no accessible projects were found + +### Requirement: Filter project autocomplete suggestions +The system SHALL filter autocomplete suggestions for project name parameters across all slash commands so that private projects are never suggested to unauthorized members. + +#### Scenario: Autocomplete invoked by squad member +- **GIVEN** a private project exists for Squad A +- **WHEN** a member of Squad A types in the `project_name` parameter of `/pm task list` +- **THEN** the private project is included in the suggested autocomplete choices + +#### Scenario: Autocomplete invoked by non-member +- **GIVEN** a private project exists for Squad A +- **WHEN** a non-member types in the `project_name` parameter of `/pm task list` +- **THEN** the private project is excluded from the suggested autocomplete choices + +### Requirement: Server manager visibility bypass +The system SHALL allow Discord server managers with administrator or manage server permissions to view and autocomplete all active projects regardless of squad assignment. + +#### Scenario: Server manager views active projects +- **GIVEN** private projects exist across multiple different squads +- **WHEN** a server manager opens the active projects roster or runs `/pm project list` +- **THEN** all active projects across all squads are displayed + +### Requirement: Ephemeral toast auto-dismissal for visual graph and hub permission feedback +The system SHALL format permission-denied notices triggered when attempting to view restricted visual graphs or execute unauthorized project actions as standardized ephemeral error toast embeds (`ToastType.ERROR`) with blockquote formatting for secondary requirement explanations, stripped leading status emojis, and dynamic relative auto-dismiss countdown timers (`*Auto-dismisses *`). + +#### Scenario: Visual graph permission denial auto-dismissal on hub +- **GIVEN** a member is unauthorized to view the visual graph for a project +- **WHEN** the member clicks "Visual Graph" on the Forum Control Hub +- **THEN** the system responds with an ephemeral error toast embed (`ToastType.ERROR`) +- **AND** the embed contains the target project name with secondary role requirements formatted as a blockquote +- **AND** the embed description includes an auto-dismiss relative countdown timer scheduled for standard toast duration + +#### Scenario: Visual graph zero accessible projects auto-dismissal +- **GIVEN** a member has access to zero project visual graphs +- **WHEN** the member clicks "Visual Graph" in the Project Management Hub or on the Forum Control Hub +- **THEN** the system responds with an ephemeral error toast embed (`ToastType.ERROR`) +- **AND** the notice formats secondary requirement guidance as a blockquote +- **AND** the embed description includes an auto-dismiss relative countdown timer scheduled for standard toast duration + +#### Scenario: Visual graph viewer project switch permission denial +- **GIVEN** a member is viewing the interactive visual graph viewer +- **WHEN** the member selects a restricted project in the project dropdown +- **THEN** the system responds with an ephemeral error toast embed (`ToastType.ERROR`) +- **AND** the embed description includes an auto-dismiss relative countdown timer scheduled for standard toast duration diff --git a/src/adapters/discord_bot/cogs/autocomplete.py b/src/adapters/discord_bot/cogs/autocomplete.py index 7de225b..50b3464 100644 --- a/src/adapters/discord_bot/cogs/autocomplete.py +++ b/src/adapters/discord_bot/cogs/autocomplete.py @@ -21,6 +21,9 @@ async def project_autocomplete( return [] try: projects = await self.project_service.list_projects(interaction.guild.id, include_archived=False) + auth_service = getattr(self, "auth_service", None) + if auth_service and interaction.user: + projects = await auth_service.filter_visible_projects(interaction.user, projects) choices = [ app_commands.Choice(name=f"{p.name} ({p.prefix})", value=p.name) for p in projects diff --git a/src/adapters/discord_bot/cogs/project_cog.py b/src/adapters/discord_bot/cogs/project_cog.py index dd59194..9a533e9 100644 --- a/src/adapters/discord_bot/cogs/project_cog.py +++ b/src/adapters/discord_bot/cogs/project_cog.py @@ -120,6 +120,17 @@ async def project_list(self, interaction: discord.Interaction) -> None: ) return + if self.auth_service: + projects = await self.auth_service.filter_visible_projects(interaction.user, projects) + + if not projects: + await send_toast( + interaction, + "You do not have permission to view any active projects in this server.", + toast_type=ToastType.INFO, + ) + return + embed = discord.Embed(title="📁 Server Projects", color=discord.Color.blue()) for p in projects: chan_str = f"<#{p.discord_channel_id}>" if p.discord_channel_id else "*No channel bound*" diff --git a/src/adapters/discord_bot/views/hub_menu.py b/src/adapters/discord_bot/views/hub_menu.py index d4098b8..a017bc4 100644 --- a/src/adapters/discord_bot/views/hub_menu.py +++ b/src/adapters/discord_bot/views/hub_menu.py @@ -9,7 +9,6 @@ from src.adapters.discord_bot.member_resolver import resolve_discord_member_name from src.adapters.discord_bot.router import DiscordViewRouter -from src.adapters.discord_bot.ui_constants import TOAST_DURATION_STANDARD from src.adapters.discord_bot.views.base_view import BaseView from src.adapters.discord_bot.views.hub_embeds import ( _format_squad_roles, @@ -188,15 +187,17 @@ async def new_task_btn(self, interaction: discord.Interaction, button: discord.u await interaction.response.send_message(embed=embed, view=picker_view, ephemeral=True) elif all_channel_projects: # Channel is bound to projects, but user lacks permission to create tasks in any of them - from src.adapters.discord_bot.menu_manager import menu_manager + from src.adapters.discord_bot.menu_manager import send_toast + from src.adapters.discord_bot.ui_constants import ToastType proj_names = ", ".join(f"**{p.name}**" for p in all_channel_projects) - await interaction.response.send_message( - f"❌ You do not have permission to create tasks in this channel's projects ({proj_names}). " - "Contact a Project Lead or server manager to be added to a project squad.", - ephemeral=True, + await send_toast( + interaction, + f"You do not have permission to create tasks in this channel's projects ({proj_names}).\n" + "> Contact a Project Lead or server manager to be added to a project squad.", + toast_type=ToastType.ERROR, ) - menu_manager.schedule_toast_dismissal(interaction, delay=TOAST_DURATION_STANDARD) + return else: # 0 channel projects (unbound channel) if len(allowed_projects) == 1: @@ -222,14 +223,16 @@ async def new_task_btn(self, interaction: discord.Interaction, button: discord.u ) await interaction.response.send_modal(modal) else: - await interaction.response.send_message( - "❌ You do not have permission to create tasks in any active projects. " - "Contact a Project Lead or server manager to be added to a project squad.", - ephemeral=True, + from src.adapters.discord_bot.menu_manager import send_toast + from src.adapters.discord_bot.ui_constants import ToastType + + await send_toast( + interaction, + "You do not have permission to create tasks in any active projects.\n" + "> Contact a Project Lead or server manager to be added to a project squad.", + toast_type=ToastType.ERROR, ) - from src.adapters.discord_bot.menu_manager import menu_manager - - menu_manager.schedule_toast_dismissal(interaction, delay=TOAST_DURATION_STANDARD) + return else: from src.adapters.discord_bot.menu_manager import menu_manager @@ -379,10 +382,14 @@ async def create_project_btn(self, interaction: discord.Interaction, button: dis else False ) if not can_manage: - await interaction.response.send_message( - "❌ You do not have permission to create projects. " - "Contact a Server Manager or hold an authorized Team Lead role.", - ephemeral=True, + from src.adapters.discord_bot.menu_manager import send_toast + from src.adapters.discord_bot.ui_constants import ToastType + + await send_toast( + interaction, + "You do not have permission to create projects.\n" + "> Contact a Server Manager or hold an authorized Team Lead role.", + toast_type=ToastType.ERROR, ) return @@ -412,7 +419,14 @@ async def tech_tree_tab(self, interaction: discord.Interaction, button: discord. projects = await self.project_service.list_projects(interaction.guild.id, include_archived=False) await self._refresh_hub_message(interaction, projects=projects) if not projects: - await interaction.response.send_message("ℹ️ No active projects found in this server.", ephemeral=True) + from src.adapters.discord_bot.menu_manager import send_toast + from src.adapters.discord_bot.ui_constants import ToastType + + await send_toast( + interaction, + "No active projects found in this server.", + toast_type=ToastType.INFO, + ) return channel_id = interaction.channel.id if interaction.channel else None @@ -429,10 +443,15 @@ async def tech_tree_tab(self, interaction: discord.Interaction, button: discord. if target_project: if self.auth_service: if not await self.auth_service.can_view_project(interaction.user, target_project.id): - await interaction.response.send_message( - f"❌ You do not have permission to view the visual graph for **{target_project.name}**.\n" - "You must hold the project squad's Discord role, be the Project Lead, or be a server manager.", - ephemeral=True, + from src.adapters.discord_bot.menu_manager import send_toast + from src.adapters.discord_bot.ui_constants import ToastType + + await send_toast( + interaction, + f"You do not have permission to view the visual graph for **{target_project.name}**.\n" + "> You must hold the project squad's Discord role, " + "be the Project Lead, or be a server manager.", + toast_type=ToastType.ERROR, ) return @@ -463,10 +482,14 @@ async def tech_tree_tab(self, interaction: discord.Interaction, button: discord. accessible_projects.append(p) projects = accessible_projects if not projects: - await interaction.response.send_message( - "❌ You do not have permission to view any project visual graphs.\n" - "You must hold a project squad's Discord role, be a Project Lead, or be a server manager.", - ephemeral=True, + from src.adapters.discord_bot.menu_manager import send_toast + from src.adapters.discord_bot.ui_constants import ToastType + + await send_toast( + interaction, + "You do not have permission to view any project visual graphs.\n" + "> You must hold a project squad's Discord role, be a Project Lead, or be a server manager.", + toast_type=ToastType.ERROR, ) return diff --git a/src/adapters/discord_bot/views/project/dashboard.py b/src/adapters/discord_bot/views/project/dashboard.py index a1bbf50..6e5e85b 100644 --- a/src/adapters/discord_bot/views/project/dashboard.py +++ b/src/adapters/discord_bot/views/project/dashboard.py @@ -215,10 +215,14 @@ async def _on_tech_tree_clicked(self, interaction: discord.Interaction) -> None: accessible_projects.append(p) if not accessible_projects: - await interaction.response.send_message( - "❌ You do not have permission to view any project visual graphs.\n" - "You must hold a project squad's Discord role, be a Project Lead, or be a server manager.", - ephemeral=True, + from src.adapters.discord_bot.menu_manager import send_toast + from src.adapters.discord_bot.ui_constants import ToastType + + await send_toast( + interaction, + "You do not have permission to view any project visual graphs.\n" + "> You must hold a project squad's Discord role, be a Project Lead, or be a server manager.", + toast_type=ToastType.ERROR, ) return @@ -319,19 +323,43 @@ async def _on_list_projects_clicked(self, interaction: discord.Interaction) -> N return projects = await self.project_service.list_projects(interaction.guild.id, include_archived=False) if not projects: - await interaction.response.send_message("No active projects found in this server.", ephemeral=True) - from src.adapters.discord_bot.menu_manager import menu_manager + from src.adapters.discord_bot.menu_manager import send_toast + from src.adapters.discord_bot.ui_constants import ToastType - menu_manager.schedule_toast_dismissal(interaction, delay=TOAST_DURATION_STANDARD) + await send_toast( + interaction, + "No active projects found in this server.", + toast_type=ToastType.INFO, + ) + return + + auth_service = self.auth_service + if not auth_service: + from src.services.auth_service import AuthService + + auth_service = AuthService(self.project_service, self.squad_service) + + accessible_projects = await auth_service.filter_visible_projects(interaction.user, projects) + if not accessible_projects: + from src.adapters.discord_bot.menu_manager import send_toast + from src.adapters.discord_bot.ui_constants import ToastType + + await send_toast( + interaction, + "You do not have permission to view any active projects in this server.\n" + "> You must hold a project squad's Discord role, be a Project Lead, or be a server manager.", + toast_type=ToastType.ERROR, + ) return - embed = build_active_projects_embed(projects, page=0, total_count=len(projects)) + embed = build_active_projects_embed(accessible_projects, page=0, total_count=len(accessible_projects)) view = ProjectActiveListView( - projects, + accessible_projects, self.project_service, self.squad_service, self.task_service, initial_interaction=interaction, + auth_service=auth_service, ) await interaction.response.edit_message(embed=embed, view=view) diff --git a/src/adapters/discord_bot/views/project/roster.py b/src/adapters/discord_bot/views/project/roster.py index c4f8daf..e9320b4 100644 --- a/src/adapters/discord_bot/views/project/roster.py +++ b/src/adapters/discord_bot/views/project/roster.py @@ -16,7 +16,7 @@ from src.services.task_service import TaskService if TYPE_CHECKING: - pass + from src.services.auth_service import AuthService logger = logging.getLogger("dgg_pm.views.project") @@ -98,6 +98,7 @@ def __init__( current_page: int = 0, query: str = "", initial_interaction: discord.Interaction | None = None, + auth_service: AuthService | None = None, timeout: float | None = EPHEMERAL_MENU_TIMEOUT_SECONDS, page_size: int = 8, ): @@ -106,6 +107,7 @@ def __init__( self.project_service = project_service self.squad_service = squad_service self.task_service = task_service + self.auth_service = auth_service self.current_page = current_page self.query = query self.page_size = page_size @@ -224,5 +226,6 @@ async def _on_back_clicked(self, interaction: discord.Interaction) -> None: self.squad_service, self.task_service, initial_interaction=interaction, + auth_service=self.auth_service, ) await interaction.response.edit_message(content=None, embed=embed, view=view) diff --git a/src/adapters/discord_bot/views/tree_view.py b/src/adapters/discord_bot/views/tree_view.py index a29582d..99efb38 100644 --- a/src/adapters/discord_bot/views/tree_view.py +++ b/src/adapters/discord_bot/views/tree_view.py @@ -75,10 +75,15 @@ async def _rerender(self, interaction: discord.Interaction) -> None: if self.auth_service: if not await self.auth_service.can_view_project(interaction.user, self.project.id): - await interaction.response.send_message( - "❌ You do not have permission to view this project's visual graph.\n" - "You must hold the project squad's Discord role, be the Project Lead, or be a server manager.", - ephemeral=True, + from src.adapters.discord_bot.menu_manager import send_toast + from src.adapters.discord_bot.ui_constants import ToastType + + await send_toast( + interaction, + f"You do not have permission to view the visual graph for **{self.project.name}**.\n" + "> You must hold the project squad's Discord role, " + "be the Project Lead, or be a server manager.", + toast_type=ToastType.ERROR, ) return @@ -161,15 +166,23 @@ async def _on_select_project(self, interaction: discord.Interaction) -> None: selected_id = UUID(values[0]) project = next((p for p in self.projects if p.id == selected_id), None) if not project: - await interaction.response.send_message("❌ Project not found.", ephemeral=True) + from src.adapters.discord_bot.menu_manager import send_toast + from src.adapters.discord_bot.ui_constants import ToastType + + await send_toast(interaction, "Project not found.", toast_type=ToastType.ERROR) return if self.auth_service: if not await self.auth_service.can_view_project(interaction.user, project.id): - await interaction.response.send_message( - f"❌ You do not have permission to view the visual graph for **{project.name}**.\n" - "You must hold the project squad's Discord role, be the Project Lead, or be a server manager.", - ephemeral=True, + from src.adapters.discord_bot.menu_manager import send_toast + from src.adapters.discord_bot.ui_constants import ToastType + + await send_toast( + interaction, + f"You do not have permission to view the visual graph for **{project.name}**.\n" + "> You must hold the project squad's Discord role, " + "be the Project Lead, or be a server manager.", + toast_type=ToastType.ERROR, ) return diff --git a/src/services/auth_service.py b/src/services/auth_service.py index 679839f..23e7b61 100644 --- a/src/services/auth_service.py +++ b/src/services/auth_service.py @@ -432,13 +432,16 @@ async def require_task_creation( project_id=project_id, ) - async def can_view_project(self, user: ActorPrincipal | Any, project_id: UUID) -> bool: + async def can_view_project(self, user: ActorPrincipal | Any, project_id: Project | UUID) -> bool: """Determines if a user is authorized to view a project's details, task list, or visual tech tree graph.""" if self.is_server_manager(user): return True user_id = _extract_user_id(user) - project = await self.project_service.get_by_id(project_id) + if isinstance(project_id, Project): + project = project_id + else: + project = await self.project_service.get_by_id(project_id) if not project: return False @@ -452,14 +455,32 @@ async def can_view_project(self, user: ActorPrincipal | Any, project_id: UUID) - return True - async def require_project_view(self, user: ActorPrincipal | Any, project_id: UUID) -> None: + async def filter_visible_projects( + self, + user: ActorPrincipal | Any, + projects: list[Project], + ) -> list[Project]: + """Filters a list of projects returning only those the user is authorized to view.""" + if not projects: + return [] + if self.is_server_manager(user): + return list(projects) + + visible: list[Project] = [] + for p in projects: + if await self.can_view_project(user, p): + visible.append(p) + return visible + + async def require_project_view(self, user: ActorPrincipal | Any, project_id: Project | UUID) -> None: """Raises PermissionDeniedError if the user cannot view the project or its visual graph.""" if not await self.can_view_project(user, project_id): + p_id = project_id.id if isinstance(project_id, Project) else project_id raise PermissionDeniedError( "You do not have permission to view this project's visual graph. " "You must hold the project squad's Discord role, be the Project Lead, or be a server manager.", user_id=_extract_user_id(user), - project_id=project_id, + project_id=p_id, ) async def can_assign_task_to_user( diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 7fcdc2e..5ffb176 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -1659,7 +1659,8 @@ async def test_pm_hub_view_auto_wires_lead_role_repo_and_has_create_project_butt await hub_view.create_project_btn.callback(inter) inter.response.send_message.assert_awaited_once() args, kwargs = inter.response.send_message.call_args - error_msg = kwargs.get("content", args[0] if args else "") + error_embed = kwargs.get("embed") + error_msg = error_embed.description if error_embed else kwargs.get("content", args[0] if args else "") assert "You do not have permission" in error_msg @@ -1728,3 +1729,377 @@ async def test_pm_cog_menu_authorizes_lead_role_for_project_creation(services, r assert proj_view.is_server_manager is True assert proj_view.new_project_btn is not None assert proj_view.new_project_btn.label == "Create Project" + + +@pytest.mark.asyncio +async def test_auth_service_filter_visible_projects(services): + proj_srv = services["project"] + squad_srv = services["squad"] + auth_srv = AuthService(proj_srv, squad_srv) + + guild_id = 999444 + # 1. Project with squad role A (role 101) + p_squad_a = await proj_srv.create_project( + guild_id=guild_id, name="Squad A Project", prefix="SQA", discord_role_id=101 + ) + # 2. Project with squad role B (role 102) and lead 5001 + p_squad_b = await proj_srv.create_project( + guild_id=guild_id, name="Squad B Project", prefix="SQB", discord_role_id=102, lead_discord_id=5001 + ) + # 3. Public project (no squad roles) + p_public = await proj_srv.create_project(guild_id=guild_id, name="Public Project", prefix="PUB") + + all_projects = [p_squad_a, p_squad_b, p_public] + + # Server Manager sees all projects + admin = _make_mock_member(9999, is_admin=True) + visible_admin = await auth_srv.filter_visible_projects(admin, all_projects) + assert len(visible_admin) == 3 + + # Member of Squad A (role 101) sees Squad A + Public (not Squad B) + member_a = _make_mock_member(2001, role_ids=[101]) + visible_a = await auth_srv.filter_visible_projects(member_a, all_projects) + assert {p.id for p in visible_a} == {p_squad_a.id, p_public.id} + + # Project lead of Squad B (user 5001, no roles) sees Squad B + Public (not Squad A) + lead_b = _make_mock_member(5001, role_ids=[]) + visible_b = await auth_srv.filter_visible_projects(lead_b, all_projects) + assert {p.id for p in visible_b} == {p_squad_b.id, p_public.id} + + # Outsider with no roles sees only Public project + outsider = _make_mock_member(3001, role_ids=[999]) + visible_outsider = await auth_srv.filter_visible_projects(outsider, all_projects) + assert {p.id for p in visible_outsider} == {p_public.id} + + # Verify can_view_project works with Project instance directly (no extra DB query) + assert await auth_srv.can_view_project(member_a, p_squad_a) is True + assert await auth_srv.can_view_project(member_a, p_squad_b) is False + + +@pytest.mark.asyncio +async def test_project_menu_view_active_projects_filtered(services): + proj_srv = services["project"] + squad_srv = services["squad"] + task_srv = services["task"] + auth_srv = AuthService(proj_srv, squad_srv) + + guild_id = 999555 + p_squad_a = await proj_srv.create_project( + guild_id=guild_id, name="Alpha Private", prefix="ALP", discord_role_id=888 + ) + + from src.adapters.discord_bot.views.project.dashboard import ProjectMenuView + from src.adapters.discord_bot.views.project.roster import ProjectActiveListView + + # 1. Outsider clicks Active Projects -> Denied with toast + outsider = _make_mock_member(4001, role_ids=[111]) + inter_outsider = MagicMock(spec=discord.Interaction) + inter_outsider.guild = MagicMock(id=guild_id) + inter_outsider.user = outsider + inter_outsider.response = MagicMock() + inter_outsider.response.send_message = AsyncMock() + + view_outsider = ProjectMenuView( + project_service=proj_srv, + squad_service=squad_srv, + task_service=task_srv, + auth_service=auth_srv, + is_server_manager=False, + ) + await view_outsider._on_list_projects_clicked(inter_outsider) + inter_outsider.response.send_message.assert_awaited_once() + kwargs = inter_outsider.response.send_message.call_args.kwargs + embed = kwargs.get("embed") + assert embed is not None + assert "You do not have permission to view any active projects in this server." in embed.description + assert ( + "> You must hold a project squad's Discord role, be a Project Lead, or be a server manager." + in embed.description + ) + assert "*Auto-dismisses Allowed with filtered view + squad_member = _make_mock_member(4002, role_ids=[888]) + inter_member = MagicMock(spec=discord.Interaction) + inter_member.guild = MagicMock(id=guild_id) + inter_member.user = squad_member + inter_member.response = MagicMock() + inter_member.response.edit_message = AsyncMock() + + view_member = ProjectMenuView( + project_service=proj_srv, + squad_service=squad_srv, + task_service=task_srv, + auth_service=auth_srv, + is_server_manager=False, + ) + await view_member._on_list_projects_clicked(inter_member) + inter_member.response.edit_message.assert_awaited_once() + roster_view = inter_member.response.edit_message.call_args.kwargs["view"] + assert isinstance(roster_view, ProjectActiveListView) + assert len(roster_view.all_projects) == 1 + assert roster_view.all_projects[0].id == p_squad_a.id + assert roster_view.auth_service is auth_srv + + # 3. Back button preserves auth_service + inter_back = MagicMock(spec=discord.Interaction) + inter_back.guild = inter_member.guild + inter_back.user = squad_member + inter_back.response = MagicMock() + inter_back.response.edit_message = AsyncMock() + + await roster_view._on_back_clicked(inter_back) + inter_back.response.edit_message.assert_awaited_once() + back_menu_view = inter_back.response.edit_message.call_args.kwargs["view"] + assert isinstance(back_menu_view, ProjectMenuView) + assert back_menu_view.auth_service is auth_srv + + +@pytest.mark.asyncio +async def test_project_list_command_authorization(services): + proj_srv = services["project"] + squad_srv = services["squad"] + task_srv = services["task"] + user_srv = services["user"] + auth_srv = AuthService(proj_srv, squad_srv) + + guild_id = 999666 + p_squad_x = await proj_srv.create_project( + guild_id=guild_id, name="Project Secret X", prefix="SECX", discord_role_id=301 + ) + p_squad_y = await proj_srv.create_project( + guild_id=guild_id, name="Project Secret Y", prefix="SECY", discord_role_id=302 + ) + + bot = MagicMock() + cog = PmCog( + bot=bot, + project_service=proj_srv, + squad_service=squad_srv, + task_service=task_srv, + user_service=user_srv, + auth_service=auth_srv, + ) + + # 1. Member of squad X runs /pm project list + member_x = _make_mock_member(5001, role_ids=[301]) + inter_x = MagicMock(spec=discord.Interaction) + inter_x.guild = MagicMock(id=guild_id) + inter_x.user = member_x + inter_x.response = MagicMock() + inter_x.response.defer = AsyncMock() + inter_x.followup = MagicMock() + inter_x.followup.send = AsyncMock() + + await cog.project_list.callback(cog, inter_x) + inter_x.followup.send.assert_awaited_once() + embed_x = inter_x.followup.send.call_args.kwargs["embed"] + assert len(embed_x.fields) == 1 + assert p_squad_x.name in embed_x.fields[0].name + assert p_squad_y.name not in embed_x.fields[0].name + + # 2. Outsider runs /pm project list -> receives permission toast + outsider = _make_mock_member(5002, role_ids=[999]) + inter_out = MagicMock(spec=discord.Interaction) + inter_out.guild = MagicMock(id=guild_id) + inter_out.user = outsider + inter_out.response = MagicMock() + inter_out.response.defer = AsyncMock() + inter_out.followup = MagicMock() + inter_out.followup.send = AsyncMock() + + await cog.project_list.callback(cog, inter_out) + inter_out.followup.send.assert_awaited_once() + embed_out = inter_out.followup.send.call_args.kwargs["embed"] + assert "You do not have permission to view any active projects" in embed_out.description + + +@pytest.mark.asyncio +async def test_project_autocomplete_authorization(services): + proj_srv = services["project"] + squad_srv = services["squad"] + task_srv = services["task"] + user_srv = services["user"] + auth_srv = AuthService(proj_srv, squad_srv) + + guild_id = 999777 + p_alpha = await proj_srv.create_project(guild_id=guild_id, name="Alpha Core", prefix="ALP", discord_role_id=401) + p_beta = await proj_srv.create_project(guild_id=guild_id, name="Beta Shield", prefix="BET", discord_role_id=402) + + bot = MagicMock() + cog = PmCog( + bot=bot, + project_service=proj_srv, + squad_service=squad_srv, + task_service=task_srv, + user_service=user_srv, + auth_service=auth_srv, + ) + + # 1. Member of Alpha squad (role 401) autocompletes + member_alpha = _make_mock_member(6001, role_ids=[401]) + inter_alpha = MagicMock(spec=discord.Interaction) + inter_alpha.guild = MagicMock(id=guild_id) + inter_alpha.user = member_alpha + + choices_alpha = await cog.project_autocomplete(inter_alpha, current="") + assert len(choices_alpha) == 1 + assert choices_alpha[0].value == p_alpha.name + + # 2. Outsider autocompletes -> 0 choices + outsider = _make_mock_member(6002, role_ids=[999]) + inter_out = MagicMock(spec=discord.Interaction) + inter_out.guild = MagicMock(id=guild_id) + inter_out.user = outsider + + choices_out = await cog.project_autocomplete(inter_out, current="") + assert len(choices_out) == 0 + + # 3. Server Manager autocompletes -> sees both projects + admin = _make_mock_member(6003, is_admin=True) + inter_admin = MagicMock(spec=discord.Interaction) + inter_admin.guild = MagicMock(id=guild_id) + inter_admin.user = admin + + choices_admin = await cog.project_autocomplete(inter_admin, current="") + assert len(choices_admin) == 2 + assert {c.value for c in choices_admin} == {p_alpha.name, p_beta.name} + + +@pytest.mark.asyncio +async def test_visual_graph_permission_toasts_scheduled_for_dismissal(services): + proj_srv = services["project"] + squad_srv = services["squad"] + task_srv = services["task"] + user_srv = services["user"] + auth_srv = AuthService(proj_srv, squad_srv) + + from src.adapters.discord_bot.menu_manager import menu_manager + from src.adapters.discord_bot.views.hub_menu import PmHubView + from src.adapters.discord_bot.views.project.dashboard import ProjectMenuView + from src.adapters.discord_bot.views.tree_view import TechTreeProjectSelectView + + guild_id = 999888 + project = await proj_srv.create_project( + guild_id=guild_id, name="Research & Policy", prefix="RES", discord_role_id=777 + ) + + outsider = _make_mock_member(7001, role_ids=[999]) + + # 1. PmHubView.tree_tab dismissal check + inter_hub = MagicMock(spec=discord.Interaction) + inter_hub.guild = MagicMock(id=guild_id) + inter_hub.user = outsider + inter_hub.channel = MagicMock(id=12345, parent_id=None) + inter_hub.response = MagicMock() + inter_hub.response.send_message = AsyncMock() + + hub_view = PmHubView( + project_service=proj_srv, + squad_service=squad_srv, + task_service=task_srv, + user_service=user_srv, + auth_service=auth_srv, + ) + + with pytest.MonkeyPatch.context() as mp: + mock_schedule = MagicMock() + mp.setattr(menu_manager, "schedule_toast_dismissal", mock_schedule) + await hub_view.tech_tree_tab.callback(inter_hub) + inter_hub.response.send_message.assert_awaited_once() + embed_hub = inter_hub.response.send_message.call_args.kwargs.get("embed") + assert embed_hub is not None + assert "You do not have permission to view the visual graph for **Research & Policy**." in embed_hub.description + assert ( + "> You must hold the project squad's Discord role, be the Project Lead, or be a server manager." + in embed_hub.description + ) + assert "*Auto-dismisses Contact a Server Manager or hold an authorized Team Lead role." in embed_create.description + assert "*Auto-dismisses You must hold a project squad's Discord role, be a Project Lead, or be a server manager." + in embed_pm_tree.description + ) + assert "*Auto-dismisses You must hold the project squad's Discord role, be the Project Lead, or be a server manager." + in embed_select.description + ) + assert "*Auto-dismisses