Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ jobs:
# - gha-curl-pipe-shell: the official Trivy installer over TLS.
# - nginx request-host/dynamic-proxy/missing-internal: standard
# same-origin reverse proxy; the upstream is an internal config value.
# - nginx possible-h2c-smuggling: purely-syntactic rule that fires on
# any WebSocket proxy (proxy_http_version 1.1 + Upgrade + Connection
# together). The /hubs SignalR proxy needs those; the actual h2c
# vector is mitigated by the $atlas_ws_upgrade/$atlas_ws_connection
# maps that clear both headers for non-`websocket` Upgrade tokens.
# - dockerfile missing-user: nginx edge master binds 80/443 (see
# .trivyignore.yaml).
semgrep scan --error \
Expand All @@ -62,6 +67,7 @@ jobs:
--exclude-rule generic.nginx.security.request-host-used.request-host-used \
--exclude-rule generic.nginx.security.dynamic-proxy-host.dynamic-proxy-host \
--exclude-rule generic.nginx.security.missing-internal.missing-internal \
--exclude-rule generic.nginx.security.possible-h2c-smuggling.possible-nginx-h2c-smuggling \
--exclude-rule dockerfile.security.missing-user.missing-user \
--metrics off \
--oss-only \
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ prototype.

Setup & ops guides live in [`docs/`](./docs/) (setup, docker, sso, secrets,
observability, retention, security hardening, Jira, [notification email via
Microsoft Graph](./docs/email-graph-setup.md)). The **architecture reference**
Microsoft Graph](./docs/email-graph-setup.md), [Teams channel
notifications](./docs/teams-setup.md)). The **architecture reference**
— High Level Design, Low Level Design, ABB/SBB catalogue and ADRs, with diagrams —
is in [`docs/architecture/`](./docs/architecture/).
33 changes: 33 additions & 0 deletions deploy/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@
# at /etc/nginx/certs (see docs/SETUP.md). HTTP is redirected to HTTPS —
# required because Entra only accepts HTTPS redirect URIs off localhost.

# WebSocket upgrade for the SignalR hub, constrained to the `websocket` value
# only. Both the Upgrade and Connection headers are derived from a map instead
# of forwarding the raw client Upgrade token: a genuine WebSocket request gets
# `Upgrade: websocket` + `Connection: upgrade`, and anything else (SignalR's
# SSE/long-poll fallbacks, or a smuggled `Upgrade: h2c`) gets both headers
# cleared. That prevents an HTTP/2-cleartext upgrade from being smuggled past
# this reverse proxy to the backend (Semgrep possible-nginx-h2c-smuggling; see
# docs/security-hardening.md).
map $http_upgrade $atlas_ws_upgrade {
default "";
websocket "websocket";
}
map $http_upgrade $atlas_ws_connection {
default "";
websocket "upgrade";
}

# HTTP -> HTTPS redirect.
server {
listen 80;
Expand Down Expand Up @@ -58,6 +75,22 @@ server {
proxy_read_timeout 60s;
}

# PI Program Board SignalR hub — same-origin reverse proxy with WebSocket
# upgrade. Long read timeout so idle sockets aren't dropped mid-session.
location /hubs/ {
resolver 127.0.0.11 ipv6=off valid=10s;
set $atlas_hub "api:8080";
proxy_pass http://$atlas_hub$request_uri;
proxy_http_version 1.1;
proxy_set_header Upgrade $atlas_ws_upgrade;
proxy_set_header Connection $atlas_ws_connection;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
}

# SPA history fallback — every other route serves index.html.
location / {
try_files $uri $uri/ /index.html;
Expand Down
64 changes: 64 additions & 0 deletions docs/architecture/adr/0060-teams-notification-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# ADR-0060 — Microsoft Teams as a third notification channel

**Status:** Accepted — extends the notification system
([ADR-0028](./0028-over-allocation-alerts.md) alerts,
[ADR-0045](./0045-per-role-demand-email.md) per-role email) and the connector
pattern ([ADR-0006](./0006-jira-pull-only-board-optional.md),
[ADR-0035](./0035-azure-devops-connector.md)).

## Context
Atlas already emits notifications on three paths — entity events (a subscribed
project/program/product changes), portfolio events ("created"), and
role-addressed governance events (a demand pinging the PMO/Architect/CTO). Each
is delivered **in-app** and, when Microsoft Graph Mail.Send is configured, by
**email**. The prototype's Integrations screen lists **Microsoft Teams** as a
connector ("Channel & chat notifications"), but it was cosmetic chrome. Teams is
where the org actually works, so notifications should be able to land there too —
without inventing a new UI or a new delivery contract.

## Decision
Add **Teams as a third channel** to the *existing* emit paths, not a parallel
system. `Notifications.{EmitToEntityAsync, EmitPortfolioAsync, EmitToRolesAsync}`
each call `TeamsNotify.EmitAsync(db, title, body)` after persisting the in-app
copy and sending email, so any event already produced is mirrored to a Teams
channel with the same title/body.

**Delivery** is an **Incoming Webhook** — created in Teams via *Workflows → "Post
to a channel when a webhook request is received"*, the forward-looking
replacement for the retired O365 connectors. Atlas POSTs an **Adaptive Card**
(the `type:"message"` + `attachments[]` envelope Workflows expects). Best-effort:
the in-app copy is always written first, so an unconfigured connector or a Teams
outage degrades silently rather than failing the originating action — identical
to the email contract.

**Configuration** lives in the `Setting` key/value store (`teams.webhookUrl`,
`teams.enabled`), reachable only through **`cap-integrations`-gated** endpoints
(`GET /integrations/teams/status`, `POST …/config`, `POST …/test`). The webhook
URL is a channel secret, so — unlike the generic `GET /settings` — it is **never
returned to the client**: status exposes only a masked `scheme://host` and the
`configured`/`enabled` booleans. The Integrations screen's Teams row becomes a
live connector (Configure modal + Send-test), mirroring the Jira/ADO rows.

## Consequences
- **+** Notifications reach Teams with zero new event plumbing and no new Graph
permissions/consent (webhook is channel-scoped, not tenant-wide).
- **+** Fully degradable and opt-in: idle until an admin pastes a URL and enables
it; muting keeps the URL but stops delivery.
- **+** Secret-safe: the URL is write-only over the API and kept out of the
broadly-readable `/settings` payload.
- **−** A single channel per instance (one webhook). Per-event or per-team routing
is a candidate follow-up, not this ADR.
- **−** Outbound-only. Two-way (actionable cards, replies) would need a Teams app
/ bot registration and is out of scope.

## Alternatives considered
- **Graph channel messages** (`POST /teams/{id}/channels/{id}/messages`) — richer,
but needs `ChannelMessage.Send` application permission (protected API, heavier
consent/licensing) for app-only posting. Rejected for the default path; the
webhook is the lowest-friction fit and reuses the connector mental model.
- **Legacy O365 "Incoming Webhook" connector** (MessageCard) — simplest, but
Microsoft is retiring O365 connectors; targeting the Workflows Adaptive-Card
envelope is future-proof.
- **A separate Teams preference per event type** — deferred; the channel rides the
existing in-app/email preferences and subscriptions rather than adding a third
column to every preference row now.
87 changes: 87 additions & 0 deletions docs/architecture/adr/0061-realtime-pi-program-board.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# ADR-0061 — Real-time PI Program Board (SignalR)

**Status:** Accepted — extends PI Planning
([Pip endpoints], the increment/objective/dependency model) and the
code-splitting policy ([ADR-0027](./0027-frontend-code-splitting.md)).

## Context
PI Planning already stored the SAFe model — `ProgramIncrement` → iterations,
`PiObjective` (business value + confidence), and `PiDependency` (deliverable→
deliverable links). The Dependencies tab rendered them as a **list**. Planners
run PI planning as a shared, synchronous ritual (the SAFe "program board"), for
which teams typically leave Atlas for a whiteboard tool. We wanted that ritual
*in* Atlas: a live swimlane board — deliverables as lanes, iterations as
columns, objectives as draggable cards, dependencies as arrows — that several
people can work at once.

## Decision
Add a **Program Board** view to the existing PI Planning screen (not a new
module — the data and screen already exist) plus a real-time layer.

**Layout (reuses existing data).** Rows are derived from each objective's linked
deliverable (`EntityType/EntityId`); columns are the increment's iterations.
Only the *column* placement is board-specific state, persisted as a JSON map
(`objectiveId → iterationId`) in the existing `Setting` store under
`pi.board.{incrementId}` — **migration-free** (the build environment can't
generate an EF migration). It's presentation state, not a domain fact;
promoting it to a first-class `PiObjective.IterationId` column is a clean
follow-up. Dependency arrows are drawn between lanes from the existing
`PiDependency` links.

**Real-time transport.** A SignalR hub (`/hubs/board`, `BoardHub`) provides
**presence**, **peer cursors**, and a **change ping**. Crucially, *no domain
data travels over the hub* — all reads/writes still go through the REST API
(`cap-schedule`), and the ping is contentless ("refetch"). Clients are grouped
per increment (`pi:{id}`). Placement moves and dependency links call REST, then
emit the ping so peers refetch the authoritative state (notify-and-refetch — no
CRDT). The client is lazy-loaded so the SignalR bundle only loads with the board.

## Security & compliance
Designed against the platform's control baseline
([ADR-0049](./0049-compliance-coverage-and-zero-trust.md)):

- **Authentication / access control (ISO 27001 A.9, A.5.15; NIST AC-3, IA-2).**
The hub is mapped with `RequireAuthorization()` whenever `Auth:Enabled`, in
lock-step with the API. Browsers can't set an `Authorization` header on the
WebSocket handshake, so the client passes the Entra bearer as an `access_token`
query value; JwtBearer is configured to read it **only** for `/hubs` paths.
- **Least privilege / no privilege escalation (NIST AC-6).** The hub carries no
mutations — it cannot change portfolio data. Every write remains behind the
`cap-schedule` REST checks and the existing audit log, so a socket can never do
more than the caller's REST permissions already allow.
- **Segregation (NIST SC-7; ISO A.8.22).** Per-increment groups mean a client
only receives events for the board it explicitly joined — no cross-board leakage.
- **Data minimisation & storage limitation (GDPR Art. 5(1)(c),(e)).** Presence
broadcasts a display name, initials and a colour derived from the opaque
connection id — **no email or stable user identifier**. Cursors are normalised
coordinates. None of it is persisted: presence/cursor state lives only in
process memory for the life of a connection and is dropped on disconnect.
- **Availability / resource abuse (NIST SC-5).** Cursor messages are
client-throttled (~16/s) and server-clamped; the connection auto-reconnects and
the board degrades to non-realtime if the hub is unreachable.
- **Transport security (ISO A.8.24).** Same-origin behind the TLS edge; nginx
proxies `/hubs` with the WebSocket upgrade (`docs` + `deploy/nginx.conf`).

## Consequences
- **+** PI planning becomes a live, multi-user board without leaving Atlas, on
top of data that already existed.
- **+** Small, auditable security surface: presence/cursor relay only; the DB
stays the single source of truth via REST.
- **+** Migration-free — ships in an environment that can't run `dotnet ef`.
- **−** Placement lives in a key/value blob rather than a typed column (promotion
is a follow-up); board layout also appears in the broad `GET /settings` dump
(non-sensitive).
- **−** Notify-and-refetch is coarser than field-level co-editing; sufficient for
planning cadence, and a CRDT/OT upgrade remains open if needed.
- **−** The .NET pieces were written to the codebase's patterns but **not compiled
here** (no SDK); a `dotnet build`/`test` gate on CI is the real confirmation.

## Alternatives considered
- **New top-level "Board" module** — rejected: duplicates the PI model and adds a
screen outside the approved prototype.
- **Poll the REST API for liveness** — rejected: no presence/cursors, and either
laggy or wasteful; SignalR is the right transport for a synchronous ritual.
- **Full CRDT co-editing** — deferred: heavier and unnecessary for card placement
and dependency links at planning cadence.
- **First-class `IterationId` column now** — deferred until a migration can be
generated; the `Setting`-backed map is the migration-free interim.
2 changes: 2 additions & 0 deletions docs/architecture/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,7 @@ ADRs are immutable once *Accepted*; to change a decision, add a new ADR that
| [0057](./0057-region-scoped-labour-rates.md) | Region-scoped labour rate lines + regional manager roles (APAC/BLOG) | Accepted |
| [0058](./0058-five-year-calendar-timeline-window.md) | Calendar timeline window (up to 5 years) on an absolute-month model | Accepted |
| [0059](./0059-task-lifecycle-timeline.md) | Task lifecycle timeline + Jira changelog-derived started/resolved timestamps | Accepted |
| [0060](./0060-teams-notification-channel.md) | Microsoft Teams as a third notification channel (channel webhook + Adaptive Card) | Accepted |
| [0061](./0061-realtime-pi-program-board.md) | Real-time PI Program Board (SignalR presence/cursors + notify-and-refetch) | Accepted |

Template: Context · Decision · Consequences · Alternatives considered.
1 change: 1 addition & 0 deletions docs/security-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ blanket disable:
| `mutable-action-tag` | Semgrep `--exclude-rule` | Actions pinned to major versions, kept current by Dependabot's `github-actions` ecosystem; SHA-pinning deferred. |
| `gha-curl-pipe-shell` | Semgrep `--exclude-rule` | Official Trivy installer over TLS from the vendor repo. |
| nginx `request-host` / `dynamic-proxy-host` / `missing-internal` | Semgrep `--exclude-rule` | Standard same-origin reverse proxy; the `proxy_pass` upstream is an internal config value, not attacker input. |
| nginx `possible-h2c-smuggling` | Semgrep `--exclude-rule` | Purely-syntactic rule that fires on any WebSocket proxy (`proxy_http_version 1.1` + `Upgrade` + `Connection` together), which the `/hubs` SignalR proxy requires. The actual h2c vector **is** mitigated: `$atlas_ws_upgrade`/`$atlas_ws_connection` maps emit `websocket`/`upgrade` only for a genuine WebSocket request and clear both headers for any other Upgrade token (incl. `h2c`), so a cleartext upgrade can't be smuggled to the backend (ADR-0061). |
| `design/` (prototype reference) | `.semgrepignore` | The approved prototype (CLAUDE.md §2) — never bundled or served, so its demo helpers aren't application AppSec. |

### CodeQL (GitHub-native, complementary)
Expand Down
54 changes: 54 additions & 0 deletions docs/teams-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Microsoft Teams notifications

Atlas can post every notification it raises (project/program/product events,
new-item alerts, and role-addressed governance events) to a **Microsoft Teams
channel** as an Adaptive Card — in addition to the in-app inbox and email.

This is **outbound-only** and **best-effort**: the in-app copy is always written
first, so if Teams is unreachable or unconfigured nothing else breaks. See
[ADR-0060](./architecture/adr/0060-teams-notification-channel.md).

## 1. Create a channel webhook (Workflows)

Microsoft has retired the classic *Office 365 connectors*; the current mechanism
is a **Workflow** with an HTTP trigger:

1. In Teams, open the target channel → **⋯ → Workflows**.
2. Choose the template **“Post to a channel when a webhook request is
received”** and complete the short wizard (pick the team + channel).
3. Copy the generated **HTTP POST URL**. This URL is a **secret** — anyone with
it can post to the channel.

## 2. Connect Atlas

1. Sign in as a user with **Edit** on *Integrations & connectors* (Platform
Admin / PMO).
2. Go to **Integrations & Settings → Connected tools → Microsoft Teams →
Configure**.
3. Paste the webhook URL, tick **Deliver notifications to this channel**, and
**Save**.
4. Click **Send test** — a card should appear in the channel within a few
seconds.

The webhook URL is stored server-side and is **never** shown again (the status
API returns only the host, e.g. `https://prod-12.westeurope.logic.azure.com`).
To rotate it, paste a new URL over the old one; to stop delivery without losing
the URL, untick the toggle; to remove it entirely, use **Disconnect**.

## 3. What gets posted

Every notification Atlas already emits — the same title and body as the in-app
and email copies. A card carries an **Atlas PPM** header plus the event title and
detail. Delivery rides the existing subscriptions and preferences; there is no
separate per-event Teams setting (a candidate follow-up).

## Troubleshooting

| Symptom | Likely cause |
| --- | --- |
| **Send test** returns an error with a 4xx | The webhook URL is wrong/expired, or the Workflow was deleted — recreate it and re-paste. |
| Test succeeds but events don't post | The **Deliver notifications** toggle is off (status shows *Muted*), or no one is subscribed to the item that changed. |
| Nothing at all, no error | The connector isn't configured — the Teams row shows *Not connected*. |

Delivery failures are logged under `Atlas.TeamsNotify` (they never fail the
originating request).
Loading
Loading