diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..30c7a9017 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Normalize all text files to LF in the repo and working tree. +# git's auto-detection keeps binary files untouched. +* text=auto eol=lf diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index ad2ec614f..ec970ec39 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -2,15 +2,17 @@ # On every PR merge to main this workflow: # 1. Collects all fragment files from changes/*.md and prepends them to # CHANGES.md, then deletes the fragments. -# 2. Increments the Minor version in setup/IdentityAtlas.psd1 and updates -# the timestamp. +# 2. Increments Minor and updates the timestamp → Major.Minor.yyyyMMdd.HHmm # # Branches never touch CHANGES.md or the version file directly — each branch # creates a uniquely named fragment file in changes/ instead, so merge # conflicts on those two files are eliminated. # # After the commit lands, docker-publish.yml is triggered via workflow_run -# so Docker images are always tagged with the new (post-bump) version. +# so Docker images are always tagged with the new (post-bump) version (:edge). +# +# Releases are handled separately via git tags (Actions → Cut Release), +# not by this workflow. # ───────────────────────────────────────────────────────────────────────────── name: Bump version on PR merge @@ -18,7 +20,8 @@ name: Bump version on PR merge on: pull_request: types: [closed] - branches: [main] + branches: + - main jobs: bump-version: @@ -28,10 +31,17 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 + - name: Generate bot token + id: bot-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_PRIVATE_KEY }} + + - uses: actions/checkout@v6 with: ref: main - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.bot-token.outputs.token }} - name: Merge changelog fragments and bump version shell: pwsh @@ -49,7 +59,7 @@ jobs: Write-Host "No changelog fragments found in changes/ -- skipping CHANGES.md update" } - # ── 2. Bump Minor version in setup/IdentityAtlas.psd1 ───────────── + # ── 2. Increment Minor + update timestamp → Major.Minor.yyyyMMdd.HHmm $content = Get-Content setup/IdentityAtlas.psd1 -Raw if ($content -match "ModuleVersion\s*=\s*'(\d+)\.(\d+)\.\d+\.\d+'") { $major = $Matches[1] diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..b3dcc44cb --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,35 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 2 * * 1' # Weekly on Monday at 02:00 UTC + +jobs: + analyze: + name: Analyze (javascript) + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + + steps: + - uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: javascript-typescript + queries: security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v4 + with: + category: /language:javascript-typescript diff --git a/.github/workflows/cut-hotfix.yml b/.github/workflows/cut-hotfix.yml new file mode 100644 index 000000000..f445faa11 --- /dev/null +++ b/.github/workflows/cut-hotfix.yml @@ -0,0 +1,85 @@ +# ─── Cut a Hotfix Release ───────────────────────────────────────────────────── +# Manually triggered. Tags the HEAD of a hotfix branch with a new patch version, +# triggering docker-publish to build :latest + :X.Y.Z.0. +# +# Usage: +# 1. git checkout -b bugfixes/fix-foo v5.2.0 ← branch from the release tag +# 2. Fix the bug, push the branch +# 3. Go to Actions → Cut Hotfix → Run workflow +# 4. Enter the branch name and new version (e.g. "5.2.1") +# 5. After the hotfix ships, open a PR to cherry-pick the fix into main +# ───────────────────────────────────────────────────────────────────────────── + +name: Cut Hotfix + +on: + workflow_dispatch: + inputs: + branch: + description: 'Hotfix branch name (e.g. bugfixes/fix-login-crash)' + required: true + version: + description: 'New version (major.minor.patch, e.g. "5.2.1")' + required: true + +jobs: + cut-hotfix: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Validate version input + run: | + if ! echo "${{ github.event.inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Version must be in Major.Minor.Patch format (e.g. 5.2.1)" + exit 1 + fi + + - name: Generate bot token + id: bot-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.branch }} + token: ${{ steps.bot-token.outputs.token }} + + - name: Create and push hotfix tag + run: | + VERSION="${{ github.event.inputs.version }}" + TAG="v${VERSION}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "Hotfix release $TAG" + git push origin "$TAG" + echo "TAG=$TAG" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + COMMIT=$(git rev-parse --short HEAD) + echo "COMMIT=$COMMIT" >> "$GITHUB_ENV" + echo "✅ Tagged ${TAG} on ${COMMIT}" + + - name: Post run summary + run: | + cat >> "$GITHUB_STEP_SUMMARY" << EOF + ## ✅ Hotfix tagged + + | | | + |---|---| + | **Tag** | \`${TAG}\` | + | **Version** | \`${VERSION}.0\` | + | **Branch** | \`${{ github.event.inputs.branch }}\` | + | **Commit** | \`${COMMIT}\` | + + docker-publish is now building \`:latest\` + \`:${VERSION}.0\` — check the [Actions tab](../../actions) for progress. + + ### Next step — cherry-pick to main + \`\`\`bash + git checkout main && git pull + git cherry-pick ${COMMIT} + gh pr create --base main --title "fix: cherry-pick hotfix from ${TAG}" + \`\`\` + EOF diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml new file mode 100644 index 000000000..497d346cd --- /dev/null +++ b/.github/workflows/cut-release.yml @@ -0,0 +1,84 @@ +# ─── Cut a Release ──────────────────────────────────────────────────────────── +# Manually triggered. Tags the current main HEAD with vX.Y.Z and lets +# docker-publish (triggered by the tag push) build and push :latest + :X.Y.Z.0. +# +# Usage: +# 1. Go to Actions → Cut Release → Run workflow +# 2. Enter the version (e.g. "5.2.0" — major.minor.patch) +# 3. The workflow creates tag v5.2.0 on the current main HEAD +# 4. docker-publish builds and pushes :latest + :5.2.0.0 +# +# Hotfixes (shipping a bugfix without including features already on main): +# 1. git checkout -b bugfixes/fix-foo v5.2.0 ← branch from the tag, not main +# 2. Fix the bug, push the branch +# 3. Run Actions → Cut Hotfix with the branch name and new version (e.g. 5.2.1) +# ───────────────────────────────────────────────────────────────────────────── + +name: Cut Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Release version (major.minor.patch, e.g. "5.2.0")' + required: true + +jobs: + cut-release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Validate version input + run: | + if ! echo "${{ github.event.inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Version must be in Major.Minor.Patch format (e.g. 5.2.0)" + exit 1 + fi + + - name: Generate bot token + id: bot-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + ref: main + token: ${{ steps.bot-token.outputs.token }} + + - name: Create and push release tag + run: | + VERSION="${{ github.event.inputs.version }}" + TAG="v${VERSION}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + echo "TAG=$TAG" >> "$GITHUB_ENV" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "✅ Tagged ${TAG} on $(git rev-parse --short HEAD)" + + - name: Post run summary + run: | + cat >> "$GITHUB_STEP_SUMMARY" << EOF + ## ✅ Release tagged + + | | | + |---|---| + | **Tag** | \`${TAG}\` | + | **Version** | \`${VERSION}.0\` | + | **Commit** | \`$(git rev-parse --short HEAD)\` | + + docker-publish is now building \`:latest\` + \`:${VERSION}.0\` — check the [Actions tab](../../actions) for progress. + + ### Hotfix workflow (if a bug is found in this release) + \`\`\`bash + git checkout -b bugfixes/fix-foo ${TAG} # branch from the tag, not main + # fix the bug, commit, push + git push origin bugfixes/fix-foo + # then run Actions → Cut Hotfix + \`\`\` + EOF diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 9e6730aee..05bfcfb78 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,6 +1,6 @@ # ─── Docker Image Publishing ───────────────────────────────────────────────── # Builds container images, runs a basic smoke test, then pushes to GitHub -# Container Registry. A broken image never reaches :latest. +# Container Registry. A broken image never reaches :latest or :edge. # # Flow: Build (local) → Start stack → Smoke test → Push # @@ -8,7 +8,9 @@ # ghcr.io/fortigi/identity-atlas — Node.js API + React frontend (web service) # ghcr.io/fortigi/identity-atlas-worker — PowerShell worker (crawlers, risk scoring) # -# Tags: latest + version from IdentityAtlas.psd1 (e.g., 5.0.20260413.1530) +# Tags pushed depend on the trigger: +# main (via bump-version): :edge + Major.Minor.yyyyMMdd.HHmm +# v* tag push: :latest + Major.Minor.Patch.0 # ───────────────────────────────────────────────────────────────────────────── name: Publish Docker Images @@ -17,8 +19,17 @@ on: workflow_run: workflows: ["Bump version on PR merge"] types: [completed] - branches: [main] + # No branches filter: the filter matches the feature branch that triggered + # bump-version (via pull_request), not main — so it would never fire. + push: + tags: + - 'v*' workflow_dispatch: + inputs: + ref: + description: 'Branch or tag to build from (e.g. main, v5.2.0)' + required: true + default: 'main' env: REGISTRY: ghcr.io @@ -29,37 +40,70 @@ jobs: runs-on: ubuntu-latest if: > github.event_name == 'workflow_dispatch' || + github.event_name == 'push' || github.event.workflow_run.conclusion == 'success' permissions: contents: read packages: write steps: - - uses: actions/checkout@v4 + - name: Determine ref and channel + id: config + run: | + EVENT="${{ github.event_name }}" + if [ "$EVENT" = "workflow_dispatch" ]; then + REF="${{ github.event.inputs.ref }}" + elif [ "$EVENT" = "push" ]; then + # Tag push: github.ref_name = v5.2.0 + REF="${{ github.ref_name }}" + else + # workflow_run from bump-version: always build main (bump-version + # only runs on PR merge to main, so the result always lands on main) + REF="main" + fi + echo "ref=$REF" >> "$GITHUB_OUTPUT" + + if [[ "$REF" == v* ]]; then + # Release tag: v5.2.0 → version 5.2.0.0 + VERSION="${REF#v}.0" + echo "channel=release" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "use_tag_version=true" >> "$GITHUB_OUTPUT" + else + echo "channel=main" >> "$GITHUB_OUTPUT" + echo "use_tag_version=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v6 with: - ref: main + ref: ${{ steps.config.outputs.ref }} - - name: Extract version from manifest + - name: Extract version id: version shell: bash run: | - version=$(grep -oP "ModuleVersion\s*=\s*'\K[^']+" setup/IdentityAtlas.psd1) - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "Module version: $version" + if [ "${{ steps.config.outputs.use_tag_version }}" = "true" ]; then + echo "version=${{ steps.config.outputs.version }}" >> "$GITHUB_OUTPUT" + echo "Version from tag: ${{ steps.config.outputs.version }}" + else + version=$(grep -oP "ModuleVersion\s*=\s*'\K[^']+" setup/IdentityAtlas.psd1) + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Version from psd1: $version" + fi - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # ── Build images locally (don't push yet) ─────────────────────── - name: Build web image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./app file: ./app/api/Dockerfile @@ -71,7 +115,7 @@ jobs: cache-to: type=gha,mode=max,scope=web - name: Build worker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ./setup/docker/Dockerfile.powershell @@ -138,8 +182,9 @@ jobs: run: docker compose -f docker-compose.qa.yml down -v 2>/dev/null || true # ── Push images (only reached if smoke test passed) ───────────── - - name: Push web image - uses: docker/build-push-action@v6 + - name: Push web image (release tag → latest) + if: steps.config.outputs.channel == 'release' + uses: docker/build-push-action@v7 with: context: ./app file: ./app/api/Dockerfile @@ -151,8 +196,9 @@ jobs: cache-from: type=gha,scope=web cache-to: type=gha,mode=max,scope=web - - name: Push worker image - uses: docker/build-push-action@v6 + - name: Push worker image (release tag → latest) + if: steps.config.outputs.channel == 'release' + uses: docker/build-push-action@v7 with: context: . file: ./setup/docker/Dockerfile.powershell @@ -162,3 +208,30 @@ jobs: ${{ env.REGISTRY }}/fortigi/identity-atlas-worker:${{ steps.version.outputs.version }} cache-from: type=gha,scope=worker cache-to: type=gha,mode=max,scope=worker + + - name: Push web image (main → edge) + if: steps.config.outputs.channel == 'main' + uses: docker/build-push-action@v7 + with: + context: ./app + file: ./app/api/Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/fortigi/identity-atlas:edge + ${{ env.REGISTRY }}/fortigi/identity-atlas:${{ steps.version.outputs.version }} + build-args: MODULE_VERSION=${{ steps.version.outputs.version }} + cache-from: type=gha,scope=web + cache-to: type=gha,mode=max,scope=web + + - name: Push worker image (main → edge) + if: steps.config.outputs.channel == 'main' + uses: docker/build-push-action@v7 + with: + context: . + file: ./setup/docker/Dockerfile.powershell + push: true + tags: | + ${{ env.REGISTRY }}/fortigi/identity-atlas-worker:edge + ${{ env.REGISTRY }}/fortigi/identity-atlas-worker:${{ steps.version.outputs.version }} + cache-from: type=gha,scope=worker + cache-to: type=gha,mode=max,scope=worker diff --git a/.github/workflows/docs-review.yml b/.github/workflows/docs-review.yml index b0893f38c..3b930152c 100644 --- a/.github/workflows/docs-review.yml +++ b/.github/workflows/docs-review.yml @@ -29,7 +29,7 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: main fetch-depth: 0 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2ebcde1e1..063d23c77 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -21,9 +21,9 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.x' @@ -45,4 +45,4 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/issue-autofix.yml b/.github/workflows/issue-autofix.yml index 391f5d24d..53e3a00f5 100644 --- a/.github/workflows/issue-autofix.yml +++ b/.github/workflows/issue-autofix.yml @@ -78,7 +78,7 @@ jobs: matrix: ${{ steps.run.outputs.matrix }} has_issues: ${{ steps.run.outputs.has_issues }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: sparse-checkout: | CLAUDE.md @@ -124,7 +124,7 @@ jobs: --repo "${{ github.repository }}" \ --add-label "fix-in-progress" - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: main fetch-depth: 0 diff --git a/.github/workflows/pr-integration.yml b/.github/workflows/pr-integration.yml index 69d13c10f..87524e541 100644 --- a/.github/workflows/pr-integration.yml +++ b/.github/workflows/pr-integration.yml @@ -30,7 +30,9 @@ name: PR Integration Tests on: pull_request: - branches: [main] + branches: + - main + - 'release/**' env: DOTNET_NOLOGO: true @@ -60,13 +62,13 @@ jobs: TEST_LLM_API_VERSION: ${{ secrets.TEST_LLM_API_VERSION }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build web image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./app file: ./app/api/Dockerfile @@ -77,7 +79,7 @@ jobs: cache-to: type=gha,mode=max,scope=web - name: Build worker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ./setup/docker/Dockerfile.powershell @@ -353,7 +355,7 @@ jobs: - name: Upload logs if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: integration-logs path: ci-logs/ @@ -377,13 +379,13 @@ jobs: continue-on-error: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build web image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./app file: ./app/api/Dockerfile @@ -394,7 +396,7 @@ jobs: cache-to: type=gha,mode=max,scope=web - name: Build worker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ./setup/docker/Dockerfile.powershell @@ -431,9 +433,9 @@ jobs: echo "Demo data loaded" - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' cache: 'npm' cache-dependency-path: app/ui/package-lock.json @@ -458,7 +460,7 @@ jobs: - name: Upload Playwright report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-report path: app/ui/playwright-report/ @@ -477,13 +479,13 @@ jobs: timeout-minutes: 75 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build web image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./app file: ./app/api/Dockerfile @@ -494,7 +496,7 @@ jobs: cache-to: type=gha,mode=max,scope=web - name: Build worker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ./setup/docker/Dockerfile.powershell @@ -556,7 +558,7 @@ jobs: - name: Upload logs if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: load-soak-logs path: ci-logs/ diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ab3804271..d64dbf6a8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,7 +16,10 @@ name: PR Checks on: pull_request: - branches: [main, dev] + branches: + - main + - dev + - 'release/**' env: DOTNET_NOLOGO: true @@ -28,7 +31,7 @@ jobs: name: 'Lint: PSScriptAnalyzer' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install PSScriptAnalyzer shell: pwsh @@ -63,12 +66,12 @@ jobs: name: 'Lint: ESLint' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' cache: 'npm' cache-dependency-path: app/ui/package-lock.json @@ -85,7 +88,7 @@ jobs: name: 'Unit Tests: Pester' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Pester shell: pwsh @@ -115,7 +118,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: pester-results path: | @@ -128,12 +131,12 @@ jobs: name: 'Unit Tests: Vitest (API)' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' cache: 'npm' cache-dependency-path: app/api/package-lock.json @@ -150,12 +153,12 @@ jobs: name: 'Lint: OpenAPI spec' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' - name: Install Spectral run: npm install -g @stoplight/spectral-cli @@ -168,12 +171,12 @@ jobs: name: 'Audit: npm audit' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' - name: Audit frontend dependencies working-directory: app/ui diff --git a/.github/workflows/test-coverage-review.yml b/.github/workflows/test-coverage-review.yml index e4a8ef7c9..3cbfa0ab3 100644 --- a/.github/workflows/test-coverage-review.yml +++ b/.github/workflows/test-coverage-review.yml @@ -35,7 +35,7 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: main fetch-depth: 0 @@ -81,9 +81,9 @@ jobs: - name: Set up Node.js if: steps.changes.outputs.has_changes == 'true' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' - name: Install test dependencies if: steps.changes.outputs.has_changes == 'true' diff --git a/.gitignore b/.gitignore index 7a59cd8f5..6c2ff294e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,4 @@ node_modules/ tools/csv-templates/demodata/ tools/csv-templates/demodataTransformed/ .claude/settings.local.json -Config/ \ No newline at end of file +Config/ diff --git a/CHANGES.md b/CHANGES.md index 0f1f05d33..11a4d906c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,366 @@ +## Changes in this PR + +- Added a **Trends** tab to the Dashboard page. Plots the % of assignments that are governed over time, plus separate charts for users, resources, and assignments growth. +- The chart starts populated on the day this version ships and grows as new days are captured. The scheduler writes one snapshot per UTC day to the new `DashboardSnapshots` table — no historical backfill, so the early section reflects only the snapshots actually captured (not a reconstructed history). +- Range selector switches between 30 days / 90 days / 1 year / 2 years. Charts render as hand-rolled SVG; no new frontend dependency. +- Added Playwright e2e coverage for the new Dashboard tab strip and Trends tab: verifies tab switching, chart container presence, and the range selector behaviour. +- New `docs/architecture/dashboard-trends.md` documents the snapshot architecture, the no-backfill decision, the API surface, and the chart rendering details. +- Pointers in `app/ui/CLAUDE.md` so future AI contributors find the new components. + +## Changes in this PR + +- Added a new crawler phase that imports application role assignments from Entra ID. For each enterprise app the crawler pulls the catalog of `appRoles[]` and the `appRoleAssignedTo` list, then writes one `AppRole` resource per (app, role), an `Application → AppRole` relationship, and one `ResourceAssignment` per user assignment. Group-typed assignments are expanded to per-user `AppRoleViaGroup` rows via `/transitiveMembers` so the matrix surfaces indirect access too. +- Wired up the existing "Apps & AppRoles" checkbox in the Crawlers wizard so toggling it actually runs the new phase. Requires `Application.Read.All` on the app registration (already required for service principals; the permission validator was already enforcing it). +- New matrix badges: `R` (App Role — direct) and `R` in a lighter shade (App Role — via group), so analysts can tell direct vs inherited app-role access at a glance. +- The dev `docker-compose.yml` now sets `restart: unless-stopped` on the postgres and web containers (the worker already had it; the prod compose file already had it for all three). After a host reboot, a Docker daemon restart, or an unexpected crash, the whole stack now auto-recovers without needing a manual `docker compose up -d`. +- Expanding a group in the matrix now fans out **any** resource that group is assigned to, not just nested parent groups. App roles a group grants its members appear as expanded sub-rows, with cells showing each user's `Indirect` (via this group) and/or `Direct` membership of that role. Previously only group-in-group nesting was visible. +- The crawler now writes the group→AppRole assignment itself (alongside the per-user expansion), so the relationship is queryable directly rather than reconstructed from the user-level rows. An idempotent backfill from existing `AppRoleViaGroup` data is applied automatically on the next bootstrap. +- Fixed three CI regressions on the PR: + - Matrix matview `REFRESH` failed on large datasets after the badge-collapse migration when a single `(resourceId, principalId)` had both a `Direct` and a `Governed` row (or any other two rows that collapsed to the same `membershipType`) — both ended up at the unique index. Migration 026 dedupes via `GROUP BY (resourceId, principalId, membershipType)` with `bool_or` on `managedByAccessPackage`. + - Playwright matrix test now walks the wizard (it used to wait for a table directly; the matrix tab now lands on an empty state until a filter is applied). Two other tests' `Matrix` button selectors gained `exact: true` so they don't match the wizard's "Create matrix" button. + - Added a permissive authenticated-API rate limiter (600 req/min per IP) so `app.use('/api', authMiddleware, …)` no longer trips CodeQL's "authorization without rate limiting" rule. +- Fixed: user detail page showed "Identity 0" even when the user was linked to an Identity. The backend was 500-ing on `/api/identities/by-user/:userId` because the secondary query referenced columns (`userId`, `userPrincipalName`) that don't exist on `IdentityMembers`. The query now correctly joins to `Principals` to surface the UPN. +- Restored the matrix row-expand button on groups that have nested groups. The `/api/groups-with-nested` and `/api/group/:id/nested-groups` endpoints filtered `principalType LIKE '%group%'` — PostgreSQL `LIKE` is case-sensitive, and the data stores `principalType='Group'` (capital G), so the filter never matched and the UI thought no groups had nested members. Switched to `ILIKE` so case is irrelevant (same fix pattern as the recent Tag-all-matching repair). +- Fixed: on the user detail graph, the OAuth2 Grants fanout collapsed every consent into a single "Microsoft Graph PowerShell" node (the client app), and clicking it opened the client app instead of the scope. The fanout now shows one node per granted scope (e.g. "User.Read.All on Microsoft Graph"), and clicking opens the scope's Resource detail page so the actual permission and its consent metadata are visible. +- Fixed the search box on every Risk Scoring subpage (users / resources / business roles / org units / identities). The queries used `LIKE` (case-sensitive in postgres) on unquoted camelCase columns (`p.displayName` rather than `p."displayName"`) — so typing into the search field returned zero rows whether the data matched or not. Five queries flipped to `ILIKE` with proper identifier quoting. Caught by the new postgres-LIKE audit test. +- Fixed: "Tag all N matching" on the Users/Resources/Identities pages did nothing. The bulk-by-filter endpoint had been silently broken since the v5 postgres migration — it still referenced the dropped temporal `ValidTo` column, used the SQL-Server-specific `@@ROWCOUNT` system variable, and left camelCase column/table names unquoted (which postgres lowercases). The endpoint now reports the real inserted count and works against the postgres schema. Search is also now case-insensitive (`ILIKE`). +- The matrix now shows every type of user/resource assignment by default — OAuth2 grants, governed business-role assignments, and any future assignment type are no longer silently dropped. Previously only Direct/Owner/Eligible/Governed memberships flowed through. As a result you'll see new rows for delegated permissions, application roles, and similar resources for the principals that have them. +- Added matrix cell badges for Governed (G) and OAuth2 Grant (A) so cells that carry those membership types render with a labelled badge instead of a generic `?`. +- Replaced the Matrix tab's inline filters with a three-step filter wizard. The matrix stays empty until a filter is applied, removing the "Top 25 most-permissioned users tenant-wide" default that often hid the slice analysts actually wanted to see. +- Step 1 picks the row type — **User × Resource** (one row per account) or **Identity × Resource** (one row per correlated person, with cells unioned across the identity's accounts). +- Steps 2 and 3 narrow the subjects and resources with include/exclude conditions on **contexts** (with optional "include descendants") and **attribute values** (any column from Principals / Identities / Resources, plus `ext.*` keys inside `extendedAttributes`). +- Every step shows live counts: subject count vs. total, resource count vs. total, and the resulting number of assignments — so the size of the sub-selection is always visible while building the filter. +- Filters can be **saved org-wide** under a name, loaded again from a dropdown at the top of the wizard, and **shared** via the Matrix tab's "Share Link" button (the entire filter is encoded in the URL). +- Dropped from the Matrix toolbar: the User slider, search box, "User Filters" bar, context-filter chip bar, and the Type/Tags column-header filter dropdowns. All of those are subsumed by the wizard. IST / SOLL / Managed / Gaps toggle, Excel export and Share Link stay. +- Added an "Adjust filter" button to the toolbar that re-opens the wizard pre-populated with the current filter. +- New backend endpoints (`POST /api/matrix/data`, `POST /api/matrix/preview`, `GET /api/matrix/columns`, and `GET|POST|PUT|DELETE /api/matrix/saved-filters`). The legacy `GET /api/permissions` endpoint stays for backward compatibility, but new flows always go through `/api/matrix/data`. +- New `SavedMatrixFilters` table (migration 023) for the org-wide named filters. +- The matrix badge column now reports only *how* a user holds a resource (D / I / O / E), not the source of the assignment. Business Role assignments, OAuth2 consents, and direct app-role assignments all render as **D** (Direct) — the user holds these directly; the fact that they came through a governance process or a user consent is already conveyed by the resource type and (for governed rows) by the cell's Access-Package coloring. App-role assignments inherited via group membership render as **I** (Indirect). +- Test coverage caught up with the recent additions: ingest validation tests cover the new `assignmentType` values (`OAuth2Grant`, `AppRole`, `AppRoleViaGroup`) and the new `relationshipType` (`HasAppRole`); the nightly Entra crawler test asserts that app-role resources are reachable, the `/identities/by-user` endpoint responds 200, and the matrix view's `membershipType` column never leaks a source-attribute type. +- Added a static-analysis test that fails the build if any route handler reintroduces a plain `LIKE` (instead of `ILIKE`) on a column where case-insensitivity was historically load-bearing — the same SQL-Server-vs-postgres footgun bit three different endpoints during the v5 migration. +- Docs caught up too: `CLAUDE.md` no longer points at the deleted `Sync-FGEntraAppRoleAssignment.ps1`, the resource-type and relationship-type tables now list `AppRole`, `Application`, `DelegatedPermission`, `HasAppRole`, `DelegatesScope`. New `docs/architecture/matrix.md` consolidates the matrix grid model — badge collapse rules, why groups don't appear as columns, expand semantics — that was previously spread across migration comments. The ingest-API doc lists the current enum values. + +## Changes in this PR + +- Split CLAUDE.md into per-area subdirectory guides (Functions/, app/api/, app/ui/) so only relevant conventions are loaded per context; root file reduced from 1110 to 251 lines +- Moved open maintenance items to docs/maintenance-backlog.md; removed all resolved entries + +## Changes in this PR + +- Added a three-way Theme toggle (Light / Auto / Dark) to the settings dropdown, replacing the previous on/off switch. The "Auto" setting follows the OS color scheme and updates live when the system preference changes. Existing dark-mode preferences are automatically migrated on first load. +- Fixed WCAG 2.0 AA contrast violations in tag and category badge colors: all ten colors were replaced with darker equivalents, achieving a minimum 4.5:1 contrast ratio. +- Fixed additional WCAG 2.0 AA contrast violations across the UI: "missing permissions" text (red-400 → red-600), "core" attribute label (indigo-500 → indigo-700), staleness indicator colors (amber-600/blue-500 → amber-700/blue-700), empty-state dashes and separators (gray-400 → gray-500), "No changes recorded" text, close button icons, "Identity not found" text, and the risk tier "None" badge label (gray-400 → gray-500). + +## Changes in this PR + +- Added `.gitattributes` to enforce LF line endings on all text files, preventing CRLF from being introduced on Windows checkouts + +## Changes in this PR + +- CI automation (version bumps, releases, hotfixes) now authenticates via the Fortigi CI Bot GitHub App instead of a personal access token, so repository rules apply to all human contributors without exception + +## Changes in this PR + +- Added CodeQL static analysis scanning on all pull requests — automatically detects security vulnerabilities and code quality issues in JavaScript and TypeScript + +## Changes in this PR + +- Updated GitHub Actions to Node.js 24 runtime: checkout@v6, setup-node@v6, setup-python@v6, upload-artifact@v7, deploy-pages@v5, docker/setup-buildx-action@v4, docker/build-push-action@v7, docker/login-action@v4 +- Updated Node.js install version in CI workflows from 20 (EOL) to 22 LTS + +## Changes in this PR + +- Fixed version link in footer not navigating to Admin → About when clicked from admin pages + +## Changes in this PR + +- Fixed "What is new" link on the dashboard — it now points to the changelog for the installed version instead of always showing the latest. + +## Changes in this PR + +- Fixed the Crawlers wizard incorrectly reporting `DelegatedPermissionGrant.Read.All` as missing when it had been granted. The wizard was checking for the GUID that belongs to `DelegatedPermissionGrant.ReadWrite.All`, so the real Read.All grant was never detected. `ReadWrite.All` is now correctly recognised as a superset of `Read.All`. +- Fixed the Entra ID crawler silently dropping OAuth2 delegated-grant resource-relationships (every consent was rejected with a 400). The validation whitelist was missing `DelegatesScope`, the relationship type introduced with the OAuth2 grants feature. +- Fixed the Entra ID crawler silently failing every `assignmentPolicies` fetch (so Access Packages showed blank Type/Review columns). Microsoft removed the `assignmentPolicies` segment from the `/beta` Graph surface — the call now goes via `/v1.0` (which still exposes it). The unused `$expand=accessPackage` and `$top=999` parameters were also dropped; `accessPackageId` is already on the base object, and this endpoint rejects `$top`. +- Fixed the Entra ID crawler dropping OAuth2 delegated-grant *assignments* with a 400 error. The `ASSIGNMENT_TYPES` validation whitelist was missing `OAuth2Grant`, the assignment type introduced with the OAuth2 grants feature. (Sibling fix to the `DelegatesScope` relationship-type entry.) +- Sign-in log sync now slices the window into 1-day chunks instead of fetching the whole 7-day window as one request. A single expired `$skiptoken` (Graph returns 400 mid-pagination on long slow fetches) used to abort the entire phase — now it loses one day, not the week, and the rest still ingests. +- Fixed the `/identities/by-user/:userId` endpoint returning 500 errors. It referenced column names (`primaryAccountUpn`, `primaryAccountId`) that only exist as aliases in other queries — now reads the real columns (`email`, `primaryPrincipalId`) and aliases them consistently. +- Entra ID crawler now fails the job (with a summary message) when any main sync phase errors out. Previously the crawler reported "completed successfully" even after several 400 responses had silently dropped entire object types. +- Sped up large ingests (e.g. 250k group memberships on a first-run tenant) by sending 1000 rows per INSERT statement instead of 200. The improvement is partially offset on first-run tenants by the new audit-history trigger now actually firing for these tables (previously it silently did nothing), so real-world wall-clock savings depend on whether history is in use — but statement count into Postgres is cut 5×. +- The nightly Entra ID test now asserts every permission returned by the wizard's validate endpoint is granted (catches GUID-mapping regressions like the one above), and the Full-Sync scenario enables every Entra object type — including Service Principals, PIM, Sign-in Logs, and OAuth2 Delegated Grants — with per-type presence checks on the resulting data. +- Crawlers wizard: step indicator is now clickable when editing an existing crawler — jump directly to any step instead of clicking Next repeatedly. +- Crawlers wizard: new Advanced section on the last step exposes `signInLogsDays` (1–30) and `aiNamePatterns` (extra regex fragments classifying service principals as AI agents). Previously these were only configurable by hand-editing the database. +- Added Export and Import buttons on the Crawlers page. Export downloads a crawler's configuration as JSON with the client secret stripped — safe to commit to a repo or share across tenants. Import opens the wizard pre-populated with the exported values; the user only has to re-enter the client secret. +- Fixed blank page when browsing the stack over plain HTTP (e.g. a dev VM on `http://hostname:3001`). The `upgrade-insecure-requests` CSP directive and Strict-Transport-Security (HSTS) header are now only emitted when `BEHIND_TLS=true` is set, i.e. when a TLS terminator sits in front of the container. Plain-HTTP deployments no longer have their asset URLs silently rewritten to HTTPS. +- Fixed scheduled Entra ID crawler runs failing with `400 Bad Request` on the first delta-mode day after the delta token was primed. Graph's `/users/delta` returns only the fields that changed per record, so the ingest API would reject records missing `displayName` and silently null every unchanged field on the ones that got through. The server now does `COALESCE(EXCLUDED.col, existing.col)` on delta upserts and skips the required-field check for delta payloads. +- Ingest validation errors are now logged with the first few concrete record-level errors (field name + reason), not just a count. Previously a 400 on the worker produced `"5 record error(s)"` in the web log with no hint which field was at fault. +- The Entra ID crawler now captures the response body of a non-2xx ingest API call via `$_.ErrorDetails.Message` (the PS 7 location — the old stream-read fallback was returning empty). Worker logs now show the actual validation error rather than just `"Response status code does not indicate success: 400"`. +- The Principals, Service Principals, Resources (groups) and Assignments (group members) phases are now wrapped in the same catch-and-continue pattern as the already-wrapped Sign-in Logs, PIM and Governance phases. A transient failure in one of these no longer aborts the entire crawl — the job continues to the remaining phases, and the final `phases` breakdown on the job record shows which phase(s) failed. The job still ends in `failed` state with a summary if any phase errored. +- Added a Sizing section to the [Docker Setup](docs/architecture/docker-setup.md) guide with recommended RAM and disk for small / medium / large tenants, plus a callout that activity-data sync (sign-in logs, and future audit/MFA feeds) is the dominant driver of growth — not principal or resource counts. The README now links there from Prerequisites. +- Each crawler job's full console output is now captured to `/data/uploads/jobs/{id}.log` via `Start-Transcript`, and the job-details modal on the Crawlers page has a new **Trace** tab showing the live transcript. It follows the tail while the job is running (polls every 3 s) and presents the final text once the job terminates — no more SSH-ing into the worker to see which access package is timing out or what the actual ingest 400 response body said. The 20 most recent logs are kept on disk; older ones are cleaned up on each new job. +- Fixed the Entra ID crawler skipping every access review definition in the Governance/AccessReviews phase, which surfaced in the UI as every access package showing "Pending first review" even when historical reviews existed, and 0 reviews on the dashboard. Graph's access-review scope shape evolved from a single `scope.query` to `resourceScope.query` (with `principalScope` carrying the reviewer side); the crawler only checked the old location, so every current-shape definition was silently dropped. The phase now inspects `scope.query`, `resourceScope.query`, and `scopes[].query` in turn, accepts both the path-style (`accessPackages/`) and filter-style (`accessPackage/id eq ''`) access-package identifiers, and logs a summary line at the end (`N total; skipped X (no scope) + Y (no AP id); kept Z`) so operators can see at a glance whether the filter is still dropping too much. +- Access review decisions fetch no longer passes `$top=999` to `/identityGovernance/accessReviews/definitions/{id}/instances/{iid}/decisions`. Graph caps that collection at 100 per page and returned 400 Bad Request for every instance — the fix earlier in this release unblocked 451 definitions, but each instance's decisions call was still failing silently, so `CertificationDecisions` stayed at zero rows. We now drop the `$top` query-string entirely and rely on `@odata.nextLink` pagination, and log the Graph response body on any remaining failures. +- The Crawlers page job list now shows a **Details** button for *every* job, not just completed/failed ones. Clicking it on a running or queued job opens the same modal with the new Trace tab live-tailing the worker's console — previously there was no way to see what a stuck-looking job was actually doing without SSH'ing into the container. +- The Governance/AccessReviews phase now emits a progress heartbeat every 25 definitions (`Access reviews: 175 of 451 definitions...`), and sets an explicit top-level step name (`Syncing access review decisions`) when it starts. The umbrella governance banner used to stay frozen on *"Catalogs, access packages, policies, reviews..."* for the full ~30 minutes the sub-phase took on a large tenant; now the UI shows actual movement. +- Fixed the Entra ID crawler's scheduled runs failing with `"records must be an array"` whenever the delta path happened to return *exactly one* changed principal or resource (every other count was fine). PowerShell's `ConvertTo-Json` silently collapses a single-element array stored as a hashtable value into a bare object (`@{records = @($user)} | ConvertTo-Json` → `{"records": {...}}` instead of `{"records": [{...}]}`), which the server's envelope validator correctly rejected. The crawler's ingest helper now wraps every array field in a `List[object]` which always round-trips as a JSON array regardless of cardinality. +- Each schedule entry can now independently specify its sync mode — **Delta (fast)** or **Full (authoritative)**. This supports the common pattern of running multiple fast deltas per day with one weekly full-refresh as a backstop (e.g. deltas at 02/08/14/20 and a full sync on Sunday at 04:00). The schedule editor shows a **Mode** dropdown per row; the worker scheduler reads the schedule's `syncMode`, falls back to the crawler's `Force full next run` override if set, and otherwise defaults to delta. +- The Recent Jobs table gained a **Mode** column showing a `full`/`delta` badge per run so you can see at a glance which scheduled runs took which path, and the job-details modal's header surfaces the same badge. Jobs queued before this change — which predate the per-schedule mode — render a `—` and continue to run in delta mode as before. +- Replaced the single **Run Now** button + separate *"Force full sync next run"* toggle on each crawler card with two explicit buttons: **Run Delta** (indigo) and **Run Full** (amber). One click queues the intended run directly — no more "set a toggle, wait for state to save, then click Run". The `POST /api/admin/crawler-jobs` endpoint accepts an optional `syncMode` body field (`"full"` or `"delta"`) for API callers; the stored `nextRunMode` column still works as a fallback for the scheduler when a schedule entry doesn't specify its own mode. + +## Changes in this PR + +## Context redesign (v6) — unified Contexts model + plugin framework + UI rewrite + +### Data model & schema + +- **Breaking (v6):** Replaced the old single-purpose `Contexts` table with a unified model: three variants (`synced` / `generated` / `manual`) and four target types (`Identity` / `Resource` / `Principal` / `System`). Membership lives in a new `ContextMembers` table. The legacy `Identities.contextId`, `Principals.contextId`, and `Resources.contextId` columns are removed; a principal can now belong to many contexts. +- New tables: `Contexts` (rewritten), `ContextMembers`, `ContextAlgorithms`, `ContextAlgorithmRuns`. The `_history` audit trigger is wired onto `Contexts`. +- Migration `019` drops the legacy `GraphResourceClusters` / `GraphResourceClusterMembers` tables (clustering is now a plugin). +- Migration `020` drops `GraphTags` / `GraphTagAssignments` and replaces them with **VIEWS** over `Contexts` + `ContextMembers`. Existing tag-JOIN queries (in `permissions.js`, `resources.js`, `details.js`) keep working unchanged. Tag IDs are now UUIDs; the UI treats them as opaque strings so no frontend change was needed. +- Migration `021` re-keys the `ix_Contexts_externalId` unique index from `(scopeSystemId, externalId)` to `(sourceAlgorithmId, scopeSystemId, externalId)` so different plugins can use the same `externalId='root'` on the same system without colliding. + +### Backend — context API surface + +- Rewrote `/api/contexts` routes from scratch: list / tree / detail / paginated members, plus full CRUD for non-synced contexts. `DELETE` allows manual + generated; synced is rejected. `POST/DELETE /:id/members` accept analyst writes on both manual and generated contexts (the plugin runner preserves `addedBy='analyst'` rows across re-runs). +- `GET /api/contexts/:id/members?include=descendants` walks `parentContextId` recursively and returns `DISTINCT ON (memberId)` so a subtree's members are visible in one paginated list. +- `GET /api/contexts` and `/api/contexts/tree` sort by `totalMemberCount DESC, displayName ASC` so big subtrees bubble up. +- `recalcMemberCountsForChain(id)` helper in `contexts/memberCounts.js` keeps `directMemberCount` and `totalMemberCount` accurate after every analyst write. Wired into the five paths that mutate `ContextMembers`: contexts member POST/DELETE and three tags routes (assign / unassign / assign-by-filter). +- `/api/contexts/:id` adds `contextCount` to the user/resource/identity detail responses and exposes `/api//:id/contexts` lazy-load endpoints — the entity-detail graph uses these to populate the new "Contexts" fanout. + +### Plugin framework (generated contexts) + +- **Plugin contract** in `app/api/src/contexts/plugins/`: registry, runner, types. Plugins are in-tree Node modules; registered plugins seed into `ContextAlgorithms` at startup. +- **Runner** does a **two-pass FK-safe upsert** (insert with `parentContextId=NULL` first, then `UPDATE` parent links) so plugins can emit nodes in arbitrary order without hitting the parent-FK constraint. +- After every run, the runner rolls up `totalMemberCount` over the produced subtrees via a recursive CTE. +- **HTTP**: `GET /api/context-plugins`, `POST /api/context-plugins/:name/dry-run`, `POST /api/context-plugins/:name/run` (async, returns `runId`), `GET /api/context-plugins/runs`, `GET /api/context-plugins/runs/:id`. +- **Plugins shipped in this PR:** + - `manager-hierarchy` — builds a tree from `Principals.managerId`. Node displayName is `" ()"` when available. Accepts `excludeNamePatterns` (regex array) so external-consultancy admin-managers (e.g. `\(Quanza\)`) can be filtered out persistently — their reports reattach to the synthetic root. + - `ad-ou-from-dn` — parses an LDAP DN into a nested OU tree. Accepts a `dnField` parameter (default `extendedAttributes.onPremisesDistinguishedName`) resolved through a whitelisted SQL-expression helper. + - `resource-cluster` — token-based clustering. Splits resource names on any non-alphanumeric, drops short/numeric/stopword tokens, creates one cluster per surviving token that appears in ≥`minMembers` resources. A resource can belong to multiple clusters. Tunable `minMembers` (default 4), `minTokenLength`, `maxTokenCoverage`, `additionalStopwords` for tenant-specific noise. See `docs/architecture/resource-cluster-algorithm.md`. +- **Plugins removed during the build** because they didn't carry their weight or weren't ready: `department-tree` (manager-hierarchy already shows department in displayName), `app-grouping-by-pattern` (`resource-cluster` does this better with no config), `business-process-llm` (stub — comes back when the LLM-call wiring lands). + +### Matrix filtering by context + +- New chip widget on the Matrix toolbar lets analysts pick one or more contexts to filter by, each with an "+sub" checkbox to include descendants. Filters AND together. +- `/api/permissions` accepts a `contextFilters` JSON query param. The `contexts/contextFilters.js` helper compiles the filter into SQL fragments using a recursive CTE on `parentContextId`. Identity/Principal-targeted filters constrain the row axis; Resource and System targets constrain the column axis. +- The filter is also pushed into the top-N user subquery — otherwise the matrix picked the 25 most-permissioned users tenant-wide and then intersected, leaving the view empty whenever the top-25 and the filtered context didn't overlap. +- Filter selections live in the matrix hash so filtered views can be bookmarked. + +### Tags as Contexts + +- Tags become a specialisation of manual Contexts (`contextType='Tag'`). The UI contract is unchanged. +- `bootstrap.js#ensureTagRoots()` runs at every container start and creates one synthetic `Tags` root per `targetType` (Principal / Resource / Identity), then reparents any orphan tag rows. New tags from `POST /api/tags` attach under the appropriate root via `getOrCreateTagRoot()`. +- The matrix `__userTag` filter and the resource detail tag chips keep working unchanged through the `GraphTags` / `GraphTagAssignments` views. +- Admin bulk-import of tags still targets the legacy table names — deferred to a follow-up because the admin-import path is scheduled for its own cleanup. + +### Crawler integration + +- CSV crawler (`tools/crawlers/csv/Start-CSVCrawler.ps1`) sends the new `Contexts.csv` / `ContextMembers.csv` shape and ingests them via the `/api/ingest/contexts` endpoint. The `/ingest/refresh-contexts` derive-from-`Principals.department` call is gone. +- Entra crawler no longer has a `Context` object type — context derivation is plugin work, not crawler work. + +### UI — Contexts tab + +- New **Contexts** tab with a two-pane layout: left selector grouped by `contextType (targetType)`, right pane with **Tree** or **List** view. Each tree node is a rounded pill with a ringed variant-colored bubble; L-shaped connector lines show the hierarchy. +- Tree + selector show **` · `** member counts when a subtree carries indirect members. +- "**+ New**" on the selector opens a three-card dispatcher: Import (jumps to Crawlers), Run plugin, Create manual. +- **RunPluginModal** — picker grouped by target type, parameter form auto-generated from each plugin's `parametersSchema` (with `scopeSystemId` rendered as a system picker and array/object params editable as JSON), Dry-run preview with counts + samples, Run that queues async + opens RunDetailPage. +- **RunDetailPage** polls `/api/context-plugins/runs/:id` every 1s until terminal; shows status, reconciliation counts, parameters, and any error. +- **ManualContextEditor** — rename, set parent, set owner, edit description, delete-with-confirm. Parent picker is the new shared **ContextPicker** modal (tree + list views, search with auto-expand on match, exclude self+descendants). +- **ContextMemberPicker** — debounced typeahead hitting `/api/identities`, `/api/resources`, `/api/users`, `/api/systems` based on `targetType`. Members are addable on both manual and generated contexts; per-row Remove on each. Remove buttons on algorithm-added rows say "Remove (will return)" so the analyst knows tuning plugin parameters is needed for a persistent removal. +- **GeneratedContextActions** panel for generated contexts — Delete-with-confirm + caveat that re-running the plugin will recreate the row unless parameters change. +- **Tree-delete** button on the right-pane header lets the analyst nuke an entire tree (root + descendants + members) without drilling in. + +### UI — entity detail rework + +- Detail pages (User / Resource / Identity / Access Package) use a shared two-column layout: **AttributesTable** on the left (real columns + `extendedAttributes` merged), **EntityGraph** on the right. +- AttributesTable uses `table-fixed` with a 40/60 colgroup so long extension-attribute labels wrap rather than squeezing the value column. No internal scroll — the panel grows to its natural height. +- **EntityGraph** is pannable via pointer drag and zoomable via wheel (clamped 0.4× – 3×). A "Reset view" button overlays the top-right when the user has moved away from default. `touch-action: none` so trackpad/touch swipes pan instead of scrolling the page. +- The graph's "Contexts" fanout uses the new `contextCount` + `/contexts` endpoints — clicking it shows every context the entity belongs to and drilling in opens that context's detail page. + +### Risk Scoring page changes + +- Cluster sections retired. Default view is "Users". A "View clusters →" link jumps to the Contexts tab. The `/api/risk-scores/clusters*` routes are removed; clustering lives in the `resource-cluster` plugin. + +### Bootstrap / quickstart fixes + +- `BEHIND_TLS=true` opt-in for HSTS + CSP `upgrade-insecure-requests`. The default `http://host:3001` quickstart no longer traps browsers into HTTPS-only for a year. +- `/api/users` was returning 500 because it still SELECT'd the dropped `u."contextId"` column. Fixed. + +### Tests + +- 8 new vitest test files, **216 tests total** (was 175 before this branch): + - `contexts/contextFilters.test.js` — 13 tests for the matrix-filter SQL helper + - `contexts/memberCounts.test.js` — 5 tests for the count-refresher (walk-up, direct/total updates, cycle safety) + - `contexts/plugins/manager-hierarchy.test.js` — 10 tests covering the algorithm + `excludeNamePatterns` + - `contexts/plugins/ad-ou-from-dn.test.js` — 10 tests including injection guards on `dnField` + - `contexts/plugins/resource-cluster/tokenize.test.js` — 16 tests for the tokenizer + stopwords + - `contexts/plugins/resource-cluster/index.test.js` — 10 integration tests for the plugin's `run()` against a mocked db + - `bootstrap.tagRoots.test.js` — 3 tests for `getOrCreateTagRoot` + +### Cleanup + +- Removed `OrgChartPage.jsx` and the `Org Chart` tab. A minimal `/api/org-chart` adapter remains (3 endpoints: manager / reports / availability) to keep the entity-detail graph and the Department detail page working until those callers are rewritten to read from the manager-hierarchy plugin tree directly. + +## Changes in this PR + +- Redesigned User, Identity, and Resource detail pages with a two-column layout: a single unified Attributes table on the left (core columns and extendedAttributes merged, with an "ext" tag marking JSON-derived fields) and an interactive relationship graph on the right. +- The graph shows the entity in the center with relationship nodes orbiting around it (for a user: Manager, Direct Reports, Context, Groups split by Direct/Indirect/Owner/Eligible, Access Packages, OAuth2 Grants, and Identity). Node size scales with the count and active nodes pulse in the logo's lime palette. +- Clicking a relationship node reveals the full list of items below the graph — e.g. clicking "Groups (Direct)" shows every direct membership, clicking "Direct Reports" shows every report — with clickable rows that open the entity's own detail tab. +- Clicking a satellite item now fans its relationships out as a further ring, drilling into that entity's own graph. Clicking the same node again collapses it; switching to a different root node replaces the chain. Works across users → access packages → resources → users, etc. +- Business Role / Access Package detail pages now share the same graph + fanout + attributes layout as users and resources. +- New "Recent Changes" timeline on every entity detail page. Shows relationship-level events from the last 30 days — assignments in/out, manager changes, resource containment shifts, linked-account add/remove — so investigating a permission issue starts with "what moved recently". Each event links to the affected counterparty's detail tab. +- Recent additions and removals also show up in the graph itself: a "Recently Added" root node (amber tint) and "Recently Removed" node (rose tint) appear when applicable, and items added in the window are tinted amber when they appear inside regular fanouts. +- Identities tab is now a simple Resources-style list (name, email, accounts, department, job title, tags) with search, filters, and bulk-tag actions. Account-correlation controls (verify / confirm / reject, HR anchor badges, orphan status, confidence bars) were removed from the list — they will return in a dedicated correlation UI later. +- Identities now support tags. Tag CRUD accepts entityType='identity'; identity rows carry their tags in the list API; a new `/api/identity-columns` endpoint feeds the filter-bar dropdowns. +- New endpoints `GET /api/user/:id/recent-changes`, `/api/resources/:id/recent-changes`, `/api/access-package/:id/recent-changes`, `/api/identities/:id/recent-changes`. Each returns up to 50 events in the last 30 days with `{ at, operation, summary, counterpartyKind, counterpartyId, counterpartyLabel }`. +- Migration 018 rewires the `_history` audit trigger so it writes a composite-key row identifier for `ResourceAssignments`, `ResourceRelationships`, and `IdentityMembers` — tables where the primary key is a tuple of foreign keys. Before this migration those inserts and deletes were silently skipped, so Recent Changes only picks up events from the migration forward. +- Identities now show aggregate counts across all linked accounts (groups, governed roles, owned resources, eligible memberships, OAuth2 grants) and a new `/api/identities/:id/assignments?type=…` endpoint returns the flattened list when a node is clicked. +- User detail endpoint now returns `membershipByType` (broken down by Direct/Indirect/Owner/Eligible) and `directReportCount`; resource detail endpoint returns `assignmentByType` (Direct/Governed/Owner/Eligible) — both used to populate the graph without loading full lists. +- HSTS and CSP `upgrade-insecure-requests` are now opt-in via `BEHIND_TLS=true`. The default quickstart serves over plain HTTP on port 3001, and these headers were trapping browsers into HTTPS-only mode with no TLS listener behind them. +- New `Test-EntityGraphNodes.ps1` nightly test walks every active node on every entity detail graph, asserts the matching list endpoint returns non-empty rows with displayName fields, and probes the `/recent-changes` endpoint shape for all four entity kinds. + +## Changes in this PR + +- Fixed org chart direct reports not showing due to invalid ValidTo filtering (temporal tables were removed in v5) + +## Changes in this PR + +- Fixed business role assignments not displaying in the detail page (API was returning `state` instead of `assignmentState` and missing the `id` field) + +## Changes in this PR + +- Added dark mode support across the entire UI; toggle via the user avatar settings dropdown +- Dark mode preference is persisted per browser in localStorage +- All pages, tables, cards, modals, and the matrix view adapt to dark mode +- Access Package column colors switch to a darker saturated palette in dark mode so they remain distinct and legible +- Risk tier badges (Critical/High/Medium/Low/Minimal) use appropriately shifted dark variants + +## Changes in this PR + +- The Crawlers page now shows **one progress card per running or queued crawler** instead of collapsing everything to a single card. Starting two crawlers back-to-back shows two cards stacked: the running one with its live step + percentage, and the queued one with a "Waiting for the worker" amber card (no fake 0% progress bar). Each card is labelled with the source config's display name so two Entra tenants are distinguishable at a glance. Cards can be dismissed individually once the job finishes. +- Added **OAuth2 Delegated Grants** as a new object type in the Entra ID crawler. When enabled, the crawler pulls `/oauth2PermissionGrants` and ingests every per-user consent (user X authorized client app Y to call target API Z on their behalf with scope S). Tenant-wide admin consents (`consentType='AllPrincipals'`) are skipped because they don't represent an individual user's authorization decision. +- New Graph permission surfaced in the Crawlers wizard: `DelegatedPermissionGrant.Read.All`. The wizard now warns if the app registration doesn't have it when the OAuth2 Grants object type is selected. `DelegatedPermissionGrant.ReadWrite.All` counts as granted (superset). +- Modelled each (client-app, target-API, scope) combination as a child `Resources` row of the client app (resourceType `DelegatedPermission`, linked via a new `ResourceRelationships.relationshipType='DelegatesScope'`). Each user's consent becomes a `ResourceAssignments` row with `assignmentType='OAuth2Grant'`. The deterministic scope-resource IDs make re-runs idempotent, and the distinct relationshipType keeps the sync from interfering with Access Package `Contains` relationships. +- Added an **OAuth2 Delegated Grants** collapsible section to the User detail page, listing every (client app, target API, scope) the user has consented to. Clicking the client-app name opens its detail tab. The section only renders when the user has at least one grant. +- Removed the hardcoded "Open in Entra ID" button from every detail page (User, Group, Resource, Access Package). The button was built from UI-side URL computation duplicated across four files, which drifted whenever Microsoft changed portal URLs. +- Replaced with a data-driven path: the crawler-calculated `ext.Link` attribute now renders as a clickable "Open in Entra ID" hyperlink directly in the Extended Attributes / Attributes table on every detail page. Single source of truth — if the crawler knows the portal URL for an object, you see it; if it doesn't, the row simply isn't there (no more links that 404). +- Bonus: any other `http(s)://…` attribute value renders as a clickable link showing the URL text, so future calculated fields (wiki links, ticket references, etc.) get the same treatment automatically without each detail page needing to opt in. +- Added a dedicated **`PrincipalActivity`** table (migration `017_principal_activity.sql`) for sign-in timestamps. Activity data no longer lives in `Principals.extendedAttributes`, which eliminates the `_history` churn that previously generated one audit row per user per daily crawl. History-trigger-free by design — only the latest known activity per combination is kept. +- The Entra ID crawler now populates **per-user sign-in activity** from the existing `signInActivity` property on `/users`, sending it to `/ingest/principal-activity` as aggregate rows (granularity A) keyed by principal. +- **Service-principal sign-in activity** is newly captured via `/beta/reports/servicePrincipalSignInActivities`. For each synced SP the crawler uploads an aggregate row with `lastSignInDateTime` and `lastNonInteractiveSignInDateTime` from the report, plus `applicationAuthenticationClient` and `delegatedClient` flavours in `extendedAttributes`. Previously ~96% of principals (SPs / MIs / AI agents) had zero activity data. +- New crawler object type **"Sign-in Logs (per-app activity)"** (`signInLogs`) pulls `/auditLogs/signIns` and aggregates to per-`(user, app)` last-activity rows (granularity B). Answers "when did user X last sign in to app Y?". Window configurable via `signInLogsDays` (default 7, capped at Graph's 30-day retention). Skipped events (no `userId`/`appId`, or app not yet synced) are logged but don't fail the run. +- Risk scoring engine now reads the stale-sign-in signal from `PrincipalActivity` first, falling back to the old `extendedAttributes.signInActivity` path so scores don't regress for tenants that haven't re-crawled yet. +- Seven new vitest cases covering `principal-activity` envelope + record validation, the aggregate-row sentinel path, the per-pair path, and pinning `AGG_RESOURCE_ID` to the migration's `DEFAULT` value. +- Fixed the Crawlers page showing "Force Stop" on every crawler config of the same type when any one of them was running. With two Entra ID crawlers side-by-side, starting one made the other's card light up as if it were running too, and clicking "Force Stop" on the wrong card would kill the real running job. The page now matches each running job back to the specific config that started it. Manual "Run Now" jobs are also stamped with their source config id on the backend so the scheduler and the UI agree on which config each job belongs to. + +## Changes in this PR + +- Added per-phase timing to the Entra ID crawler. Each `SyncXxx` block (Principals, ServicePrincipals, Resources, Assignments, PIM, Governance, RefreshViews) now wraps a `Stopwatch` and the final log prints a breakdown table: `Principals 42.3s (4.7%)` etc., plus an "Other" line covering setup and anything outside the instrumented blocks. No behaviour change — just instrumentation — so operators can see exactly where a slow crawl spent its time before deciding where to optimise. +- Added two calculated fields to every Entra-synced object's `extendedAttributes`: a **`Link`** deep-link into the Entra admin portal (same URL the Identity Atlas UI opens on its "Open in Entra ID" buttons), and **`_OuPath`** — a forward-slash-separated OU path derived from any DN-shaped value on the object. Example: `CN=204374,OU=Users,OU=Accounts,OU=Clients,DC=fujitsu,DC=ad,DC=portofrotterdam,DC=com` becomes `Clients\Accounts\Users`. +- Works across **all synced object types** (users, groups, service principals, managed identities, AI agents). Every DN-shaped attribute on each record is converted — the conversion is opt-in by DN shape, not by hardcoded field name, so custom `fgGroupDN` / `ownerDN` / etc. extensions get translated too. +- `onPremisesDistinguishedName` is now fetched by default on the user sync so on-prem-synced users get an `_OuPath` out of the box. Cloud-native users leave it null and emit no extra field. +- Four new helper functions under `tools/powershell-sdk/helpers/`: `Add-FGEntraCalculatedAttributes`, `Get-FGEntraPortalLink`, `Test-FGDistinguishedName`, `Convert-FGDistinguishedNameToOUPath`. 21 new Pester tests pin the DN detection rules, the canonical OU-path conversion example, and each portal-URL blade shape. +- Fixed the worker running "orphaned" crawl jobs after a web container restart. When the web container restarts mid-crawl, its bootstrap marks any `running` CrawlerJob as `failed`. Previously the worker had no way to know its current job had been killed and would keep processing for another 60–90 minutes until the crawl finished naturally, blocking every queued job behind it. The crawler's progress endpoint already returned HTTP 409 when asked to update a non-active job — the crawler was silently swallowing that signal. It now propagates 409 as a clean abort, the dispatcher marks the job failed, and the worker moves on to the next queued job within one poll cycle (~30 seconds). + +## Changes in this PR + +- Added a one-click Excel Power Query workbook download under **Admin → Data → Excel Power Query Workbook**. Clicking "Generate token & download workbook" mints a read-only API key, embeds it in the workbook's Settings sheet alongside the API URL, and streams the file back. Data analysts can drop the file on any machine and refresh from any deployment without ever editing M code. +- Tabs included: Systems, Principals, Resources, Assignments, Identities, IdentityMembers, ResourceRelationships. Each tab includes pre-written paginated Power Query M code that the user pastes into Power Query Editor (Data → Get Data → Other Sources → Blank Query). A future iteration will swap in a hand-built template that loads queries automatically — the rest of the plumbing is already in place. +- New read-only API token type (`fgr_…`). Tokens are accepted on GET requests to non-admin endpoints only — they cannot mutate data or reach any admin endpoint, even if leaked. Stored as SHA-256 hashes; plaintext is shown to the operator exactly once at creation. Tokens can be revoked from the same admin section, which immediately stops every workbook holding them from refreshing. +- New bulk list endpoints for the join tables that previously had no flat listing: `/api/assignments`, `/api/identity-members`, `/api/resource-relationships`. All are paginated (`?limit=1000&offset=N`, max 10 000 per page) with optional `?systemId=N` filter, returning `{ data, total }` like every other list endpoint. + +## Changes in this PR + +- Fixed Docker image publishing not triggering automatically after PR merges to main + +## Changes in this PR + +- Fixed Quick Start documentation: Image channel switching code now includes Windows PowerShell syntax + +## Changes in this PR + +- Replaced long-lived release branches with git tags for release management — hotfixes now ship only the fix, without features already merged to main +- Added "Cut Release" workflow: tags vX.Y.Z on main HEAD, triggers :latest publish +- Added "Cut Hotfix" workflow: tags a hotfix branch commit as a new patch version, triggers :latest publish +- Removed release branch concept — no more "Compare & pull request" banner confusion after cutting a release + +## Changes in this PR + +- Added branching and versioning strategy reference page under docs/architecture +- Added `--pull always` flag to Quick Start commands in README, quickstart, docker-setup, and index docs so users always get the newest image +- Added `.env` setup step (copy from template) to all Quick Start sections +- Added tabbed Linux/macOS and Windows code blocks throughout docker-setup and local-dev docs +- Fixed version pinning example in quickstart to use release format (5.2.0.0) instead of edge timestamp format +- Fixed history.md to link to the branching strategy doc instead of referencing CLAUDE.md +- Aligned local-dev.md with docker-setup.md: added --build note for first run, tabbed stop/reset commands + +## Changes in this PR + +- Added an About page showing the MIT license text and Software Bill of Materials +- The version string in the footer is now a clickable link to the About page + +## Changes in this PR + +- Added Service Principals to the Entra ID crawler. When the "Service Principals" object type is selected, the crawler now pulls all Entra service principals (enterprise apps, managed identities, AI agents) and writes them to the Principals table alongside user accounts — unblocking future Azure RM role-assignment imports that reference these identities. +- New classification helper (`Get-FGServicePrincipalType`) tags each service principal as `ManagedIdentity`, `AIAgent`, or `ServicePrincipal` based on Graph's `servicePrincipalType`, well-known Microsoft AI platform tags (CopilotStudio, PowerVirtualAgents, AzureOpenAI, CognitiveServices), the Entra Agent ID markers (`AgenticInstance`, `AgenticApp`, `power-virtual-agents-*`), and display-name heuristics. Custom AI-name patterns can be supplied per crawler config via `aiNamePatterns`. +- Extra SP metadata (`appId`, `servicePrincipalType`, `publisherName`, `homepage`, `tags`, `servicePrincipalNames`, `notes`) is stored in `extendedAttributes` so it shows up in the Users filter dropdown and detail pages. +- Added principal-type sub-tabs to the Users page ("All / Users / Service Principals / Managed Identities / AI Agents") so the list stays navigable after an SP sync adds thousands of non-human identities. The active tab is preserved in the URL hash (`#users?type=AIAgent`). + +## Changes in this PR + +- Fixed the Cut Release workflow rejecting valid version inputs like "5.2" due to a non-portable regex + +## Changes in this PR + +- Fixed the "Filters" dropdown on the Users and Resources pages — attribute fields (Department, Job Title, etc.) and their values are populated again. The list had regressed to showing only "User Tag" because column discovery was looking up table names in the wrong case after the PostgreSQL migration. +- Added regression tests pinning the PostgreSQL table and column casing used by column discovery, so the same mismatch can't slip back in. +- Added extended-attribute fields to the Users and Resources filter dropdowns. Keys stored inside the `extendedAttributes` JSON blob (e.g. `userType`, `onPremisesSyncEnabled`, `extensionAttribute5`, `city`, `country`) are now filterable via the UI, labelled with a "(ext)" suffix so they're distinguishable from regular columns. + +## Changes in this PR + +- Added stable release branch model: `release/vX.Y` branches are cut from `main` and receive only bugfixes, giving customers a stable `:latest` Docker image that does not change when new features land on `main` +- New `cut-release.yml` workflow creates a release branch and sets the initial version (e.g. `5.2.0.0`) with one click from GitHub Actions +- Main branch builds now push the `:edge` Docker tag instead of `:latest`, so customers on `:latest` only receive intentional releases +- Production hotfixes merged to a release branch increment the patch version (`5.2.0.0` → `5.2.1.0`) and push `:latest` automatically +- `docker-compose.prod.yml` now supports an `IMAGE_TAG` environment variable to select the image channel (`latest`, `edge`, or a pinned version); customers get `latest` by default +- The footer now shows the running version; edge builds display a prominent amber "edge" badge so it is immediately obvious which channel is running +- README and Docker Setup documentation updated with step-by-step `.env` setup for both customers and developers, image channel selection guide, and a full environment variable reference +- Fixed PR checks (lint, unit tests, integration tests) to also run on pull requests targeting `release/**` branches, so hotfixes receive the same gate as feature PRs +- Fixed release cut workflow: the initial `X.Y.0.0` image is now published to `:latest` immediately after the release branch is created, not only after the first bugfix merges +- `release/**` branches are now protected: direct commits are blocked, a pull request is required, and the `PR Summary` status check must pass before merging; repository admins retain bypass rights so the automated version bump can still land + +## Changes in this PR + +- Fixed Risk Scoring card on Dashboard to link directly to Admin → Risk Scoring subtab +- Added Software Bill of Materials (SBOM) documentation listing all components, dependencies, and infrastructure elements +- Fixed SBOM navigation entry in mkdocs.yml to ensure proper documentation site build +- Added automatic scheduling for risk scoring runs (similar to crawler scheduling) +- Risk classifiers can now have multiple schedules configured via Admin → Risk Classifiers +- Scheduled scoring runs execute in the background and re-score all entities with the active classifier +- Schedules support hourly, daily, and weekly frequencies +- Added "Select All" and "Deselect All" buttons to the attribute picker in the EntraID crawler wizard, making it easier to manage large attribute lists +- Fixed automated version bumps failing due to branch protection requiring pull requests +- Fixed Dashboard Risk Scoring card link to properly navigate to Admin → Risk Scoring sub-tab +- Fixed direct reports not showing in demo dataset (org chart queries now filter for current records in temporal Principals table) +- Added in-browser wizard for generating account correlation rulesets via LLM (Admin → Account Correlation) +- Users can now create correlation signals and account type rules through a conversational UI instead of PowerShell commands +- Wizard follows the same pattern as Risk Scoring: Sources → Generate & Refine → Save +- Fixed Risk Scoring page not refreshing automatically after completing the risk profile wizard +- Fixed crawler schedules not firing when created via legacy wizard (scheduler now supports both `schedule` and `schedules` config formats) +- Fixed detail page tabs not updating when switching between different users, resources, or other entities — tabs now show the correct entity data immediately +- Fixed Org Chart UI so all departments are visible when scrolled horizontally; departments no longer fall off the edge of the viewable area +- Fixed Extended Attributes displaying "[object Object]" for complex values like sign in activity — now shows properly formatted JSON +- Automated version bumping on PR merge — `bump-version.yml` Action increments the Minor version in `setup/IdentityAtlas.psd1` and updates the timestamp on every PR merge to `main`. Branches no longer touch the version file, eliminating recurring merge conflicts on `setup/IdentityAtlas.psd1`. +- Automated changelog merging on PR merge — branches now create a fragment file in `changes/` instead of editing `CHANGES.md` directly. The same `bump-version.yml` Action collects all fragments and prepends them to `CHANGES.md` on merge, eliminating recurring merge conflicts on `CHANGES.md`. +- Fixed Sync Log empty state message to reference adding a crawler instead of Start-FGSync +- Added historical performance graphs to Containers tab showing last 10 minutes of CPU, memory, and network usage for each container +- Added syntax highlighting and collapsible sections to JSON display in risk profile wizard for improved readability +- Fixed upgrade instructions to use `--pull always` instead of a separate `pull` + `up -d`, so a single command always fetches the latest image from the registry +- Merged issue triage and nightly auto-fix into a single unified workflow that classifies and fixes bugs immediately when an issue is opened +- Added automatic issue classification: bugs vs feature requests, with priority labels (critical, high, medium, low) +- Feature requests are now labeled as `enhancement` with a priority but require manual triage before auto-fix +- Added nightly re-evaluation job (23:00 Amsterdam time) that checks issues labeled `needs-clarification` or `cant-autofix` for new comments — if enough detail has been added, the issue is promoted to `ready-to-fix` and auto-fixed +- Fixed auto-fix prompt to use changelog fragments instead of editing CHANGES.md and setup/IdentityAtlas.psd1 directly + ## Changes in this branch - **Auto-fix workflow: CI-validated fixes with retry** — Reworked the nightly auto-fix into a two-attempt pipeline: Claude investigates and fixes the issue, submits a draft PR, waits for the full CI pipeline (integration tests, Playwright E2E, load tests) to validate it. If CI fails, Claude gets the failure logs and tries to fix the broken tests in a second attempt. Three possible outcomes: `auto-fixed` (PR + CI green), `cant-autofix` with reason (couldn't produce a fix), or `cant-autofix` with PR link (fix exists but CI still failing — needs human review). The prompt now instructs Claude to think step-by-step before implementing. Issues labeled `cant-autofix` or `auto-fixed` are excluded from future runs (remove the label to retry). diff --git a/CLAUDE.md b/CLAUDE.md index 71d014599..8f2ba5316 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,23 +4,24 @@ > 1. Create or update `changes/.md` (e.g. `changes/fix-mssql-shim-boolean.md`) with bullet points describing the functional change (user-facing language, not implementation details). > 2. Do **NOT** edit `CHANGES.md` directly — the `bump-version.yml` Action merges all fragments into it on PR merge. > 3. Do **NOT** edit `ModuleVersion` in `setup/IdentityAtlas.psd1` — version bumps are also automated by the same Action. -> This eliminates merge conflicts on both files. ## Project Overview -Identity Atlas is a Docker-deployed application that pulls authorization data from Microsoft Graph (and other systems via CSV) into a **PostgreSQL** database, then surfaces it through a React role-mining UI. The worker container ships PowerShell crawler scripts but no longer touches the database directly — all persistence flows through the Node.js API. - -**v5 architectural change (April 2026):** The database backend moved from SQL Server to PostgreSQL. SQL Server Developer Edition is free for development but cannot be used in production per Microsoft's EULA, and SQL Server Express has a 10 GB hard cap that's too small for the tenants Identity Atlas targets. Postgres has no licensing surface and no size limits. Temporal tables were dropped — they had no native postgres equivalent and were unused in practice. The v4 schema files lived in `app/db/*.ps1` (deleted in v5); the new schema is a versioned set of `.sql` files in `app/api/src/db/migrations/` applied automatically by the web container at startup. See [docs/architecture/postgres-migration.md](docs/architecture/postgres-migration.md) for the full migration plan. +Identity Atlas is a Docker-deployed application that pulls authorization data from Microsoft Graph (and other systems via CSV) into a **PostgreSQL** database, then surfaces it through a React role-mining UI. The worker container ships PowerShell crawler scripts; all persistence flows through the Node.js API. **Key Information:** - **Languages:** PowerShell (crawlers), JavaScript (Node API + React UI), SQL (postgres migrations) -- **Primary Purpose:** Microsoft Graph API wrapper with PostgreSQL data persistence (Docker-hosted) -- **Author:** Wim van den Heijkant -- **Company:** Fortigi -- **GitHub:** https://github.com/Fortigi/IdentityAtlas -- **Distribution:** PowerShell Gallery +- **Stack:** PostgreSQL 16 + Node.js API (port 3001) + PowerShell worker — all in Docker +- **Author:** Wim van den Heijkant / Fortigi — https://github.com/Fortigi/IdentityAtlas - **Current Version:** 5.x.yyyyMMdd.HHmm (auto-bumped by `bump-version.yml` on every PR merge to `main`) +**Subdirectory coding guides (loaded contextually):** +- `Functions/CLAUDE.md` — PowerShell function conventions, patterns, Graph API permissions +- `app/api/CLAUDE.md` — Node.js API conventions, testing, migrations +- `app/ui/CLAUDE.md` — React/UI conventions, dark mode, shared utilities + +**Architecture docs:** `docs/architecture/` contains postgres-migration, context-redesign, entity-detail-pages, llm-and-risk-scoring, docker-setup, csv-import-schema. + --- ## Branching & Versioning Strategy @@ -31,40 +32,41 @@ Identity Atlas is a Docker-deployed application that pulls authorization data fr | Branch | Purpose | PR required? | Approval required? | |--------|---------|-------------|-------------------| -| `main` | Stable trunk. Never commit directly. | Yes | Yes (at least 1) | +| `main` | Integration trunk. Never commit directly. Merges push `:edge` Docker tag. | Yes | Yes (at least 1) | | `feature/` | All feature work. Created from `main`. Merged back to `main` via PR. | Yes (to `main`) | No | -| `bugfixes/` | Bug fixes. Created from `main`. Merged back to `main` via PR. | Yes (to `main`) | No | +| `bugfixes/` | Bug fixes. Branch from `main` for pre-release fixes; branch from a **release tag** for hotfixes. | Yes (to `main`) | No | **Rules:** -- `feature/` and `bugfixes/` branches must be branched off `main`. -- All merges to `main` go through a Pull Request — no direct pushes ever. -- Branch names: `feature/` or `bugfixes/` (lowercase, hyphens). Example: `feature/risk-score-export`, `bugfixes/fix-login-redirect`. -- When starting work, always create a new `feature/` or `bugfixes/` branch. Never work directly on `main`. -- **One issue per branch.** Each branch must fix exactly one issue or implement exactly one feature. Never combine fixes for separate, unrelated issues into a single branch or PR. Exception: if a single code change genuinely resolves more than one issue (e.g. the same root cause), both issue numbers may be referenced in the commit and PR — but this should be rare and the connection must be explicit. +- `feature/` branches must be branched off `main`. +- `bugfixes/` branches branch from `main` for pre-release fixes. For hotfixes to an already-released version, branch from the release tag: `git checkout -b bugfixes/fix-foo v5.2.0`. +- Hotfix commits must be cherry-picked back to `main` via a separate PR so the fix is included in future releases. +- All merges go through a Pull Request — no direct pushes to `main` ever. +- Branch names: `feature/` or `bugfixes/` (lowercase, hyphens). +- When starting work, always create a new branch. Never work directly on `main`. +- **One issue per branch.** Each branch must fix exactly one issue or implement exactly one feature. ### Version Number Scheme -Version format (4 parts, PowerShell-compatible): `Major.Minor.yyyyMMdd.HHmm` +| Context | Version format | Example | Docker tag pushed | +|---------|---------------|---------|-------------------| +| `main` dev builds | `Major.Minor.yyyyMMdd.HHmm` | `5.3.20260419.1430` | `:edge` | +| Release tags (`v*`) | `Major.Minor.Patch.0` | `5.2.1.0` | `:latest` | +| `feature/*` / `bugfixes/*` | — | — | Nobody | -| Branch | Version format | Who updates it | When | -|--------|---------------|----------------|------| -| `main` | `Major.Minor.yyyyMMdd.HHmm` | `bump-version.yml` GitHub Action (automated) | On every PR merge. Increments `Minor`, updates timestamp. | -| `feature/*` / `bugfixes/*` | — | **Nobody** | Never touch `setup/IdentityAtlas.psd1` on a branch. | +**Who updates versions:** -**How to apply:** +| Context | Who updates it | When | +|---------|---------------|------| +| `main` dev builds | `bump-version.yml` (automated) | Every PR merge — increments `Minor`, updates timestamp | +| Release tags | `cut-release.yml` / `cut-hotfix.yml` (automated) | When you run Actions → Cut Release or Cut Hotfix | +| `feature/*` / `bugfixes/*` | **Nobody** | Never touch `setup/IdentityAtlas.psd1` on a branch | -1. **Starting a branch**: Branch from `main`. Leave `setup/IdentityAtlas.psd1` untouched. -2. **After any code change on a branch**: Add bullets to `changes/.md`. Do not edit `CHANGES.md` or `ModuleVersion`. -3. **When merging feature/bugfixes → main via PR**: The `bump-version.yml` Action runs automatically after merge and commits the Minor increment + new timestamp to `main`. The `docker-publish.yml` Action then triggers on that commit to build and push Docker images with the updated version. -4. **Major version bump**: Edit `setup/IdentityAtlas.psd1` manually on `main` (via a PR) for a breaking change. Increment `Major`, reset `Minor` to `0`. +### Changelog Fragments -### Changelog fragments (replaces direct CHANGES.md edits) +Every feature/bugfixes branch must add a fragment file under `changes/`. **Never edit `CHANGES.md` directly.** -Every feature/bugfixes branch must add a fragment file under `changes/`. **Never edit `CHANGES.md` directly** — the `bump-version.yml` Action merges all fragments into it on PR merge, eliminating merge conflicts. - -- **Filename:** `changes/.md` — use the branch name or a short slug (e.g. `changes/fix-mssql-shim-boolean.md`). One file per branch is typical; the name just needs to be unique across open PRs. +- **Filename:** `changes/.md` — use the branch name or a short slug. One file per branch is typical. - **Content:** Bullet points only. User-facing language. No implementation details. -- `CHANGES.md` itself is append-only and owned by CI — never edit it on a branch. **Fragment format:** ```markdown @@ -72,84 +74,18 @@ Every feature/bugfixes branch must add a fragment file under `changes/`. **Never - ``` -**Rules for writing entries:** +**Rules:** - Write in user-facing language ("Added X", "Fixed Y", "Improved Z"). - Do not describe internal refactors unless they affect observable behavior. - Add a bullet immediately after each meaningful change — don't batch them up at the end. --- -## Major Features - -### 1. In-Browser Crawler Wizard -- The Crawlers page in Admin walks the user through Microsoft Graph credentials → permission validation → object type selection → identity filter → custom attributes → schedules -- Works against any Entra ID tenant without leaving the browser - -### 2. Microsoft Graph API Integration -- Easy authentication (service principal & interactive) -- Automatic token refresh and pagination handling -- CRUD operations for Azure AD/Entra ID resources -- Required permissions: `User.Read.All`, `Group.Read.All`, `GroupMember.Read.All`, `Directory.Read.All`, `EntitlementManagement.Read.All`, `AccessReview.Read.All`, `AuditLog.Read.All` - -### 3. PostgreSQL Database (v5) -- **Audit History**: Trigger-based change tracking via shared `_history` table with JSONB snapshots -- **Automatic Migrations**: Versioned `.sql` files in `app/api/src/db/migrations/` applied on startup -- **High-Performance Sync**: Bulk upsert operations via the Ingest API -- **Legacy PowerShell SQL functions**: Still available for backward compatibility but no longer used in Docker deployment - -### 4. Identity Governance & Compliance Sync -- **Complete Access Package Sync**: Catalogs → GovernanceCatalogs, packages → Resources (`resourceType='BusinessRole'`), assignments → ResourceAssignments (`assignmentType='Governed'`), resource scopes → ResourceRelationships (`relationshipType='Contains'`), policies → AssignmentPolicies, requests → AssignmentRequests, reviews → CertificationDecisions -- **Group Membership Sync**: Direct, transitive, eligible (PIM), and owner relationships -- **Orchestrated Sync**: `Start-FGSync` orchestrates all Entra ID operations; `Start-FGCSVSync` orchestrates CSV-based imports for external systems -- **Parallel Execution**: Up to 6 entity types concurrently via runspace pool -- **CSV Import**: Canonical schema with 9 file types (Systems, Resources, Users, Assignments, ResourceRelationships, Contexts, Identities, IdentityMembers, Certifications). Source-specific transforms happen outside the crawler — see `tools/csv-templates/transforms/`. Schema templates downloadable from Admin → Crawlers. Auto-classifies Direct assignments to BusinessRole resources as Governed. See [docs/architecture/csv-import-schema.md](docs/architecture/csv-import-schema.md) -- **Analytical Views**: 12+ SQL views for IST vs SOLL analysis, approval metrics, access reviews - -### 5. Docker Deployment -- All services run in Docker containers: PostgreSQL 16, web (Node.js API + React frontend), worker (PowerShell crawlers + scheduler) -- Crawler scheduling lives in the `CrawlerConfigs` SQL table; the worker polls every minute and queues jobs -- See [docker-setup.md](docs/architecture/docker-setup.md) for full architecture and operations - -### 6. Role Mining UI -- **Web Application**: React + Vite + Tailwind + TanStack Table v8 served by the `web` Docker container on port 3001 -- **Authentication**: Optional Entra ID (MSAL) with support for both v1 and v2 token formats; defaults to no-auth for local Docker -- **Tab Navigation**: Eleven pages — Matrix, Users, Resources, Systems, Access Packages, Sync Log, Risk Scoring, Identities, Org Chart, Performance — plus dynamic detail tabs. Optional tabs (Risk Scores, Identities, Org Chart, Performance) are hidden by default and can be enabled per-user via the settings dropdown. -- **User Preferences**: Clicking the user avatar in the top-right opens a settings dropdown with toggle switches for optional tabs. Preferences are stored per-user in the `GraphUserPreferences` SQL table (auto-created). User identified by Entra ID `oid` claim; `anonymous` fallback for no-auth mode. -- **Matrix View**: User-group permission heatmap with drag-and-drop row reordering -- **Staircase Sort**: Default row order groups rows by their leftmost AP bucket, creating a visual staircase pattern; unmanaged groups at the bottom. Custom drag order persists via versioned localStorage (bump `ROW_ORDER_VERSION` in `useMatrixRowOrder.js` when changing default sort logic) -- **Multi-Type Badges**: Cells show individually colored badges per membership type (D, I, E); multi-type cells show all badges side by side -- **Owner Row Separation**: Owner (O) memberships are shown in separate rows suffixed with "(Owner)". D, I, E stay together; ownership is a fundamentally different relationship. Synthetic rows use `id: groupId__owner` with `realGroupId` pointing to the original group -- **Access Package Coloring**: Each AP gets a distinct color from a 15-color palette; managed cells are colored by their governing AP -- **Multi-AP Indicator**: Cells managed by multiple access packages show a count badge -- **Access Package Categories**: Categories are single-assignment labels for access packages (unlike tags, an AP can only have one category). Categories are managed on the Access Packages page. Stored in `GraphCategories` and `GraphCategoryAssignments` SQL tables (auto-created). Categories drive the AP column ordering in the Matrix view. -- **Access Package Columns**: SOLL columns sorted first by category name, then by total assignment count within each category; uncategorized APs appear at the end. Category boundaries are marked with thicker borders and a colored indicator stripe. -- **IST/SOLL Toggle**: Filter matrix to show managed (SOLL), unmanaged (IST), or all assignments -- **Column Header Filters**: Type and Tags columns have filter dropdowns; Tags includes a "(Blank)" option (sentinel `BLANK_TAG`) to show groups without tags -- **Server-Side User Limit**: Slider (default 25) limits data at the SQL level for large environments -- **Excel Export**: Full matrix export with AP columns next to users (matching on-screen layout), AP-colored cells, rich-text multi-type badges, and multi-AP notes -- **Entity Detail Pages**: Click any user, group, or access package name to open a detail tab. Shows all attributes, group memberships/members with type badges, access package assignments, and version history diffs from the `_history` audit table. Multiple detail tabs can be open simultaneously; each has a close button. Hash-based routing (`#user:id` / `#group:id` / `#access-package:id`) supports bookmarking. Drill-through navigation between user and group details. -- **Access Package Detail Page**: Lazy-loaded collapsible sections: Assignments (active users with UPN and assigned date), Resource Assignments (groups/resources with Member/Owner role badges), Assignment Policies (auto-assigned vs request-based with scope), Access Reviews (decisions with auto-review indicator for AAD Access Reviews), Pending Requests, Version History. Review status differentiates "Not required" (no review configured) from "Pending first review" (review configured but no instance yet). -- **Performance Monitoring**: ON by default (Performance page in Admin); `PERF_METRICS_ENABLED=false` opts out at startup. Server-side middleware captures per-request timing with per-SQL-query breakdowns. `Server-Timing` HTTP headers appear in browser DevTools. Performance sub-tab shows endpoint summaries (P50/P95/P99), recent requests, and slowest requests. Export JSON for offline analysis. Ring buffer (1000 entries) — zero overhead when disabled. -- **Deployment**: `docker compose up -d` — all services run in containers, configured via the in-browser wizard (Admin → Crawlers) - -### 7. Identity Risk Scoring (v5 — in-app) -**v5 architecture (April 2026):** Risk scoring is now driven entirely from the -UI. The PowerShell helpers were retired during the postgres rewrite. The new -flow lives behind Admin → Risk Scoring → "New profile" and runs through a -multi-step wizard. See [docs/architecture/llm-and-risk-scoring.md](docs/architecture/llm-and-risk-scoring.md) for the full design. - -- **In-browser wizard**: Sources → Generate & Refine → Save Profile → Classifiers → Run Scoring. Conversational refinement lets the user iterate ("drop NIS2", "add the medical-device division", "we don't actually use SAP") before saving. -- **Multi-provider LLM**: Anthropic Claude, OpenAI, **and Azure OpenAI** are supported via a single provider abstraction (`app/api/src/llm/providers.js`). Configure per-tenant on Admin → LLM Settings. -- **Secrets vault**: All credentials (LLM API keys, per-URL scraper credentials) live in an envelope-encrypted `Secrets` table. AES-256-GCM with per-row data keys wrapped by a master key from `IDENTITY_ATLAS_MASTER_KEY`. The vault module ([app/api/src/secrets/vault.js](app/api/src/secrets/vault.js)) is general-purpose — other parts of the app can adopt the same pattern. -- **URL scraping**: Risk profile generation accepts internal URLs (wiki, ISMS, intranet) as additional context. Optional per-URL Basic or Bearer credentials live in the same vault. Scraping is fetch-on-create — no long-term indexing in v1. -- **Postgres-native scoring engine**: [app/api/src/riskscoring/engine.js](app/api/src/riskscoring/engine.js). Layer 1 (direct classifier match, weight 0.60) and a lightweight Layer 2 (small-group bonus, weight 0.25) are implemented. Layers 3 and 4 (structural hygiene, cross-entity propagation) are placeholders kept in the formula for future extension. -- **Background scoring runs**: `POST /api/risk-scoring/runs` queues a run, the engine executes in the same Node process, the wizard polls `GET /api/risk-scoring/runs/:id` for progress. -- **Risk Tiers**: Critical (90-100), High (70-89), Medium (40-69), Low (20-39), Minimal (1-19), None (0). -- **Worker dependency**: zero. The worker container has no LLM SDK and no API key — risk scoring runs in the web container. - -### 8. Universal Data Model (v3.1) - -The data model supports importing authorization data from any system, not just Entra ID. In v3.1, the Resources/ResourceAssignments/ResourceRelationships tables are also used for governance data (business roles, governed assignments, resource grants), creating a unified model. +## Data Model + +### Universal Data Model (v3.1) + +The data model supports importing authorization data from any system. Resources, ResourceAssignments, and ResourceRelationships are also used for governance data (business roles, governed assignments, resource grants). ``` ┌──────────┐ @@ -174,56 +110,46 @@ The data model supports importing authorization data from any system, not just E **Tables:** - **Systems** — Connected authorization sources (EntraID, SharePoint, AzureRM, DevOps, etc.) -- **Resources** — Any permission-granting resource (groups, directory roles, app roles, sites) **and** business roles (`resourceType='BusinessRole'`) with `extendedAttributes` JSON. Governance columns: `catalogId`, `isHidden` -- **ResourceAssignments** — Who has access to what (`resourceId` + `principalId` + `assignmentType`). Includes governed assignments (`assignmentType='Governed'`) with governance columns: `policyId`, `state`, `assignmentStatus`, `expirationDateTime` -- **ResourceRelationships** — Resource-to-resource links (Contains, GrantsAccessTo). Includes business role resource grants (`relationshipType='Contains'`) with governance columns: `roleName`, `roleOriginSystem` +- **Resources** — Any permission-granting resource (groups, roles, app roles, sites) **and** business roles (`resourceType='BusinessRole'`) with `extendedAttributes` JSON +- **ResourceAssignments** — Who has access to what (`resourceId` + `principalId` + `assignmentType`). Governed assignments use `assignmentType='Governed'` +- **ResourceRelationships** — Resource-to-resource links (`Contains`, `GrantsAccessTo`). Business role resource grants use `relationshipType='Contains'` - **Principals** — User accounts from any system with `principalType` and `extendedAttributes` JSON -- **OrgUnits** — Organizational units (departments, teams) calculated from data or synced from HR -- **Identities** — Real persons aggregated from multiple accounts (from account correlation) +- **Identities** — Real persons aggregated from multiple accounts (account correlation) - **IdentityMembers** — Links identities to their principals across systems -**Core + JSON pattern:** Both Resources and Principals use frequently-queried attributes as real SQL columns (displayName, department, resourceType) and system-specific attributes in `extendedAttributes` JSON column. This enables SQL indexing on hot columns while keeping the schema extensible. +**Resource types in use:** + +| `resourceType` | Source | What it represents | +|---|---|---| +| `EntraGroup` | Entra crawler | Security / Microsoft 365 group | +| `BusinessRole` | Governance sync (Entra access packages, Omada business roles) | Wraps groups via `relationshipType='Contains'`; assigned to users via `assignmentType='Governed'` | +| `Application` | OAuth2 / AppRoles phases | Enterprise application (service principal). Doesn't grant access by itself — it's the parent of AppRole / DelegatedPermission children | +| `AppRole` | `SyncAppRoles` phase | One synthetic resource per (Application, appRoleId). Parent app linked via `relationshipType='HasAppRole'`. Assigned to users via `assignmentType='AppRole'` (direct) or `assignmentType='AppRoleViaGroup'` (expanded from a group's role) | +| `DelegatedPermission` | `SyncOAuth2Grants` phase | One synthetic resource per (clientSP, targetApiSP, scope). Parent app linked via `relationshipType='DelegatesScope'`. Assigned to users via `assignmentType='OAuth2Grant'` | -**Unified resource model (v3.1):** Business roles are stored in the same Resources table as groups and other resources, distinguished by `resourceType='BusinessRole'`. This means business roles participate in the same views, risk scoring, and clustering as any other resource. Similarly, governed assignments and resource grants reuse ResourceAssignments and ResourceRelationships with specific `assignmentType` and `relationshipType` values. +**Assignment types in use:** + +`Direct`, `Indirect`, `Owner`, `Eligible` (the four "how does this user have it" types) plus the *source-attribute* types `Governed`, `OAuth2Grant`, `AppRole`, `AppRoleViaGroup`. The matrix view (`vw_ResourceUserPermissionAssignments`) collapses the source-attribute types in its `membershipType` output — see [`docs/architecture/matrix.md`](docs/architecture/matrix.md) for the badge-display rules. + +**Relationship types in use:** `Contains` (BusinessRole → group), `HasAppRole` (Application → AppRole), `DelegatesScope` (Application → DelegatedPermission), `GrantsAccessTo` (reserved). + +**Core + JSON pattern:** Frequently-queried attributes are real SQL columns; system-specific attributes live in `extendedAttributes` JSON. **Backward compatibility:** All queries prefer new tables (Resources, Principals) with automatic fallback to legacy tables (GraphGroups, GraphUsers). -### 9. Universal Governance Model (v3.1 — Unified) +### Contexts (v6, April 2026) -The governance model supports business roles, certifications, and access policies from any IGA platform — not just Entra ID Access Packages. In v3.1, the model was unified with the resource model: business roles, their assignments, and their resource grants are stored in the shared Resources, ResourceAssignments, and ResourceRelationships tables. Only governance-specific tables remain separate. +Contexts are a unified data surface. Single `Contexts` table with three variants (synced / generated / manual) and four target types (Identity / Resource / Principal / System). Membership lives in `ContextMembers`. -``` - ┌──────────────────┐ - │GovernanceCatalogs │ - └────────┬─────────┘ - │ catalogId - ┌────────▼─────────┐ - │ Resources │ (resourceType='BusinessRole') - └────────┬─────────┘ - ┌───────────┬───────┼───────┬───────────┐ - │ │ │ │ │ - ┌────▼──────┐ ┌──▼───┐ ┌▼─────┐ ▼────────┐ │ - │Resource │ │Resour│ │Assign│ │Assignme│ │ - │Relation- │ │ceAssi│ │ment │ │nt │ │ - │ships │ │gnment│ │Polici│ │Requests│ │ - │(Contains) │ │s │ │es │ └────────┘ │ - └───────────┘ │(Gove-│ └──────┘ │ - │rned) │ ┌──────────▼──┐ - └──────┘ │Certification│ - │Decisions │ - └─────────────┘ -``` +Legacy tables — `OrgUnits`, `GraphResourceClusters`, `GraphResourceClusterMembers`, `Identities.contextId`, `GraphTags`, `GraphTagAssignments` — are gone. Tags are now `contextType='Tag'` Contexts (with backward-compat views). Clustering, org-chart derivation, tags, and business processes are all context-algorithm plugins that register at startup and emit generated Contexts. + +See `docs/architecture/context-redesign.md` for the design. -**Shared tables** (created by `Initialize-FGSystemTables`, extended by `Initialize-FGGovernanceTables`): -- **Resources** (`resourceType='BusinessRole'`) — Business roles stored alongside groups, directory roles, app roles, etc. Extra governance columns: `catalogId`, `isHidden` -- **ResourceAssignments** (`assignmentType='Governed'`) — Business role assignments stored alongside direct/eligible assignments. Extra governance columns: `policyId`, `state`, `assignmentStatus`, `expirationDateTime` -- **ResourceRelationships** (`relationshipType='Contains'`) — Business role resource grants stored alongside other resource links. Extra governance columns: `roleName`, `roleOriginSystem` +### Governance Model (v3.1 — Unified) -**Governance-specific tables** (created by `Initialize-FGGovernanceTables`): -- **GovernanceCatalogs** — Containers for business roles (Entra: Catalogs, Omada: Policy groups) -- **AssignmentPolicies** — Assignment rules with `policyConditions` JSON for ABAC (Entra: Assignment Policies, Omada: Context rules). References `resourceId` (the business role) -- **AssignmentRequests** — Request/approval workflow history. References `resourceId` (the business role) -- **CertificationDecisions** — Review/certification results with `certificationScopeType` (BusinessRole or ResourceAssignment). References `resourceId` +Business roles, certifications, and access policies from any IGA platform. Business roles and their assignments/resource grants are stored in the shared Resources, ResourceAssignments, and ResourceRelationships tables. + +**Governance-specific tables:** GovernanceCatalogs, AssignmentPolicies, AssignmentRequests, CertificationDecisions. **IGA platform mapping:** @@ -237,737 +163,106 @@ The governance model supports business roles, certifications, and access policie | AssignmentRequests | — | AP Assignment Request | — | Access Request | | CertificationDecisions | — | AP Access Review | CRA | Certification | -**Breaking change (v3.0 → v3.1):** The old governance model had 7 separate tables (GovernanceCatalogs, BusinessRoles, BusinessRoleResources, BusinessRoleAssignments, BusinessRolePolicies, BusinessRoleRequests, CertificationDecisions). In v3.1, three tables were absorbed into the shared resource model: BusinessRoles → Resources, BusinessRoleAssignments → ResourceAssignments, BusinessRoleResources → ResourceRelationships. Two tables were renamed: BusinessRolePolicies → AssignmentPolicies, BusinessRoleRequests → AssignmentRequests (with `businessRoleId` → `resourceId`). Existing v3.0 deployments must re-sync to populate the unified tables. Tags/categories can be exported from the old setup and imported into the new one. - -## Repository Structure - -``` -FortigiGraph/ -├── Functions/ # All PowerShell functions -│ ├── Base/ # Core authentication and HTTP request functions (19) -│ │ ├── Get-FGAccessToken*.ps1 # Token acquisition (3 variants) -│ │ ├── Invoke-FGGetRequest.ps1 # HTTP GET with auto-pagination -│ │ ├── Invoke-FGPostRequest.ps1 # HTTP POST wrapper -│ │ ├── Invoke-FGPatchRequest.ps1 # HTTP PATCH wrapper -│ │ ├── Invoke-FGPutRequest.ps1 # HTTP PUT wrapper -│ │ ├── Invoke-FGDeleteRequest.ps1 # HTTP DELETE wrapper -│ │ ├── Update-FGAccessTokenIfExpired.ps1 # Shared token refresh helper -│ │ └── ... # Token management, secure config helpers -│ │ -│ ├── Generic/ # Microsoft Graph API operations (49) -│ │ ├── Get-FG*.ps1 # Retrieve operations -│ │ ├── New-FG*.ps1 # Create operations -│ │ ├── Set-FG*.ps1 # Update operations -│ │ ├── Add-FG*.ps1 # Add operations (members, resources) -│ │ └── Remove-FG*.ps1 # Delete/remove operations -│ │ -│ ├── Sync/ # High-performance data sync operations (32) -│ │ ├── Start-FGSync.ps1 # Orchestrates all Entra ID sync operations -│ │ ├── Start-FGCSVSync.ps1 # Orchestrates CSV-based sync for external systems -│ │ ├── Sync-FGUser.ps1 # Sync users to GraphUsers (legacy) -│ │ ├── Sync-FGPrincipal.ps1 # Sync users to Principals -│ │ ├── Sync-FGGroup.ps1 # Sync groups to GraphGroups (legacy) -│ │ ├── Sync-FGGroupMember.ps1 # Sync direct group memberships -│ │ ├── Sync-FGGroupEligibleMember.ps1 -│ │ ├── Sync-FGGroupOwner.ps1 -│ │ ├── Sync-FGEntraDirectoryRole.ps1 # Sync directory roles → Resources -│ │ ├── Sync-FGEntraAppRoleAssignment.ps1 # Sync app role assignments → Resources + ResourceAssignments -│ │ ├── Sync-FGResourceRelationship.ps1 # Sync resource-to-resource links -│ │ ├── Sync-FGSystem.ps1 # Ensure system record exists -│ │ ├── Sync-FGOrgUnit.ps1 # Calculate OrgUnits from Principals -│ │ ├── Sync-FGAccessPackage.ps1 # Sync access packages → Resources (resourceType='BusinessRole') -│ │ ├── Sync-FGAccessPackageAssignment.ps1 # Sync AP assignments → ResourceAssignments (assignmentType='Governed') -│ │ ├── Sync-FGAccessPackageResourceRoleScope.ps1 # Sync AP resource scopes → ResourceRelationships (relationshipType='Contains') -│ │ ├── Sync-FGAccessPackageAssignmentPolicy.ps1 # Sync AP policies → AssignmentPolicies -│ │ ├── Sync-FGAccessPackageAssignmentRequest.ps1 # Sync AP requests → AssignmentRequests -│ │ ├── Sync-FGAccessPackageAccessReview.ps1 # Sync AP reviews → CertificationDecisions -│ │ ├── Sync-FGCatalog.ps1 # Sync catalogs → GovernanceCatalogs -│ │ ├── Sync-FGMaterializedViews.ps1 # Refresh materialized SQL views -│ │ ├── Sync-FGCSVSystem.ps1 # Sync systems from CSV -│ │ ├── Sync-FGCSVPrincipal.ps1 # Sync principals from CSV -│ │ ├── Sync-FGCSVResource.ps1 # Sync resources from CSV -│ │ ├── Sync-FGCSVResourceAssignment.ps1 # Sync resource assignments from CSV -│ │ ├── Sync-FGCSVIdentity.ps1 # Sync identities from CSV -│ │ ├── Sync-FGCSVBusinessRole.ps1 # Sync business roles from CSV → Resources -│ │ ├── Sync-FGCSVCertification.ps1 # Sync certifications from CSV -│ │ ├── Invoke-FGPrincipalMigration.ps1 # Migrate GraphUsers → Principals -│ │ ├── Invoke-FGResourceModelMigration.ps1 # Migrate GraphGroups → Resources -│ │ ├── Initialize-FGSyncTable.ps1 # Shared table lifecycle helper -│ │ └── New-FGDataTableFromGraphObjects.ps1 # Shared DataTable builder -│ │ -│ ├── SQL/ # SQL operations (31) — legacy, used outside Docker -│ │ ├── Invoke-FGSQLCommand.ps1 # Helper for connection lifecycle -│ │ ├── Connect-FGSQLServer.ps1 # Connect with firewall & ConfigFile -│ │ ├── Initialize-FGSQLTable.ps1 # Create SQL tables (legacy) -│ │ ├── Initialize-FGSystemTables.ps1 # Create Systems, Resources, Principals, OrgUnits, Identities tables -│ │ ├── Initialize-FGResourceViews.ps1 # Resource-based permission views (v3.1) -│ │ ├── Initialize-FGResourceIndexes.ps1 # Resource-based indexes (v3.1) -│ │ ├── Initialize-FGGovernanceTables.ps1 # Create 4 governance tables + ensure governance columns on 3 shared tables -│ │ ├── Initialize-FGRiskScoreTables.ps1 # Create risk score tables -│ │ ├── Initialize-FGAccessPackageViews.ps1 -│ │ ├── Initialize-FGGroupMembershipViews.ps1 # Legacy group views (backward compat) -│ │ ├── Initialize-FGGroupMembershipIndexes.ps1 # Legacy group indexes -│ │ └── ... # Query, bulk ops, server management, export/import -│ │ -│ ├── Specific/ # Higher-level helper functions (9) -│ │ └── Confirm-FG*.ps1 # Idempotent confirmation/creation -│ │ -│ │ # (Azure deployment functions removed in April 2026 — Docker-only now) -│ │ -│ └── RiskScoring/ # Identity risk scoring engine (17) -│ ├── New-FGRiskProfile.ps1 # LLM-assisted org context discovery -│ ├── New-FGRiskClassifiers.ps1 # Generate risk detection classifiers -│ ├── Invoke-FGRiskScoring.ps1 # 4-layer batch scoring engine -│ ├── Save-FGResourceClusters.ps1 # Group related resources into clusters -│ ├── Invoke-FGLLMRequest.ps1 # Shared LLM API helper (Anthropic/OpenAI) -│ ├── Save-FGRiskProfile.ps1 # Persist profile to SQL -│ ├── Save-FGRiskClassifiers.ps1 # Persist classifiers to SQL -│ ├── Get-FGRiskProfile.ps1 # Read profile from SQL -│ ├── Get-FGRiskClassifiers.ps1 # Read classifiers from SQL -│ ├── Export-FGRiskProfile.ps1 # Export profile to JSON file -│ ├── Export-FGRiskClassifiers.ps1 # Export classifiers to JSON file -│ ├── Import-FGRiskProfile.ps1 # Import profile from JSON file -│ ├── Import-FGRiskClassifiers.ps1 # Import classifiers from JSON file -│ ├── Invoke-FGAccountCorrelation.ps1 # Cross-system account correlation -│ ├── New-FGCorrelationRuleset.ps1 # Generate correlation rules via LLM -│ ├── Save-FGCorrelationRuleset.ps1 # Persist correlation rules to SQL -│ └── Get-FGCorrelationRuleset.ps1 # Read correlation rules from SQL -│ -├── Config/ # Configuration templates -│ └── tenantname.json.template -│ -├── UI/ # Role Mining Web Application -│ ├── backend/ # Node.js + Express API server -│ │ └── src/ -│ │ ├── routes/permissions.js # API endpoints (permissions, AP groups, sync log) -│ │ ├── routes/categories.js # Category CRUD, AP list, category assignments -│ │ ├── routes/details.js # User/group/resource detail endpoints with version history -│ │ ├── routes/resources.js # Resource CRUD, filtering, column discovery -│ │ ├── routes/systems.js # Systems CRUD, owners, statistics -│ │ ├── routes/orgUnits.js # OrgUnit tree, detail, members -│ │ ├── routes/identities.js # Identity correlation results -│ │ ├── routes/riskScores.js # Risk score reading + analyst override endpoints -│ │ ├── routes/clusters.js # Resource cluster management endpoints -│ │ ├── routes/orgChart.js # Manager hierarchy tree endpoints (cached 5 min) -│ │ ├── routes/governance.js # Access review compliance monitoring -│ │ ├── routes/preferences.js # User preferences (tab visibility) with auto-created table -│ │ ├── routes/perf.js # Performance metrics API (/api/perf, export, clear) -│ │ ├── middleware/auth.js # Entra ID JWT validation (v1+v2 tokens) -│ │ ├── middleware/perfMetrics.js # Request timing + Server-Timing headers -│ │ ├── perf/collector.js # Ring buffer metrics collector with aggregation -│ │ ├── perf/sqlTimer.js # SQL query timer wrapper (per-query instrumentation) -│ │ ├── db/connection.js # PostgreSQL (pg) connection pool + graceful shutdown -│ │ ├── db/columnCache.js # Shared column discovery cache (5-min TTL) -│ │ └── mock/data.js # Mock data for local dev -│ └── frontend/ # React + Vite + Tailwind -│ └── src/ -│ ├── App.jsx # Root component, tab navigation, userLimit state -│ ├── auth/AuthGate.jsx # MSAL authentication gate -│ ├── hooks/ -│ │ ├── usePermissions.js # API hook with debounced refetch -│ │ ├── useMatrixRowOrder.js # Row order persistence (versioned localStorage) -│ │ └── useEntityPage.js # Shared hook for Users/Groups pages (search, filter, tags, pagination) -│ ├── utils/exportToExcel.js # Excel export with AP colors & rich text -│ └── components/ -│ ├── MatrixView.jsx # Main matrix orchestrator (staircase sort, managedApMap, apIdToIndex) -│ ├── PermissionGrid.jsx # TanStack Table grid view -│ ├── SyncLogPage.jsx # Sync log viewer -│ ├── UserDetailPage.jsx # User/principal detail with attributes, memberships, history -│ ├── ResourceDetailPage.jsx # Resource detail with extendedAttributes, members, history -│ ├── OrgUnitDetailPage.jsx # OrgUnit detail with members and sub-units -│ ├── SystemsPage.jsx # Connected systems overview with stats and owners -│ ├── GroupsPage.jsx # Legacy groups page (redirects to Resources) -│ ├── RiskScoringPage.jsx # Risk score visualization with override controls -│ ├── OrgChartPage.jsx # Manager hierarchy tree with risk propagation -│ ├── DepartmentDetailPage.jsx # Department risk profile deep dive -│ ├── AccessPackageDetailPage.jsx # AP detail with assignments, resources, policies, reviews, history -│ ├── GovernancePage.jsx # AP review compliance dashboard (disabled) -│ ├── RiskScoreSection.jsx # Shared risk score display component -│ ├── PerfPage.jsx # Performance metrics viewer (summary, recent, slowest, export) -│ └── matrix/ # Matrix sub-components -│ ├── MatrixToolbar.jsx # Filters, IST/SOLL, slider -│ ├── MatrixCell.jsx # Individual cell (AP-colored bg, multi-type badges) -│ ├── MatrixGroupRow.jsx # DnD-agnostic row (sortable props injected by SortableRow) -│ ├── SortableMatrixBody.jsx # Lazy-loaded: DnD + virtual scrolling wrapper -│ └── MatrixColumnHeaders.jsx # AP color palette (15 colors), column filters -│ -├── _Build/ # Build and publishing scripts -│ └── CreatePSD.ps1 # Module manifest generation -│ -├── _Test/ # Testing scripts and documentation -│ -├── tools/ -│ ├── crawlers/ -│ │ ├── entra-id/Start-EntraIDCrawler.ps1 # Entra ID crawler (runs in worker container) -│ │ └── csv/Start-CSVCrawler.ps1 # CSV crawler (canonical schema, runs in worker) -│ └── csv-templates/ -│ ├── schema/ # Header-only CSV files defining the Identity Atlas schema -│ │ ├── Systems.csv, Resources.csv, Users.csv, Assignments.csv, -│ │ ├── ResourceRelationships.csv, Contexts.csv, Identities.csv, -│ │ ├── IdentityMembers.csv, Certifications.csv -│ └── transforms/ # Source-specific transform scripts -│ └── omada-to-identityatlas.ps1 # Omada Identity → Identity Atlas schema -│ -├── test/ -│ └── nightly/ -│ ├── Run-NightlyLocal.ps1 # Full nightly test suite -│ ├── Run-NightlyAndReview.ps1 # Wrapper with Claude auto-review on failure -│ ├── Test-EntraIdCrawler.ps1 # Entra crawler scenarios + deep assertions -│ ├── Test-LLMSubstrate.ps1 # LLM/secrets/risk-profile smoke test -│ ├── Register-ReviewSchedule.ps1 # Windows Task Scheduler registration -│ └── claude-review-prompt.md # Prompt template for the Claude review agent -│ -├── docs/ -│ └── architecture/ -│ ├── csv-import-schema.md # CSV import canonical schema specification -│ ├── llm-and-risk-scoring.md # LLM, secrets vault, risk scoring design -│ ├── postgres-migration.md # PostgreSQL migration plan -│ └── docker-setup.md # Docker deployment architecture -│ -├── FortigiGraph.psm1 # Module entry point (auto-loads all functions) -├── setup/IdentityAtlas.psd1 # Module manifest (version auto-bumped by CI) -├── README.md # User documentation -└── CLAUDE.md # This file - AI assistant development guide -``` - -### Function Count by Category - -| Category | Count | Purpose | -|----------|-------|---------| -| **Base** | 22 | Authentication, HTTP operations, setup wizard, token management | -| **Generic** | 49 | Graph API CRUD operations | -| **Sync** | 32 | High-performance data sync (Start-FGSync + CSV sync + entity syncs + migration + helpers) | -| **SQL** | 31 | SQL database operations (tables, views, indexes, bulk ops, system tables, governance tables) | -| **Specific** | 9 | High-level idempotent helpers | -| **RiskScoring** | 17 | LLM-assisted risk profiling, batch scoring, cluster analysis, account correlation | -| **Total** | **~160 functions** | (Azure deployment functions removed April 2026) | - -## Architecture & Design Patterns - -### 1. Module Loading Strategy - -The module loads functions from the `Functions/` directory via dot-sourcing in `FortigiGraph.psm1`: - -```powershell -$base = @( Get-ChildItem -Path (Join-Path $PSScriptRoot 'functions\base') -Include *.ps1 -Recurse ) -$generic = @( Get-ChildItem -Path (Join-Path $PSScriptRoot 'functions\generic') -Include *.ps1 -Recurse ) -$specific = @( Get-ChildItem -Path (Join-Path $PSScriptRoot 'functions\specific') -Include *.ps1 -Recurse ) -$SQL = @( Get-ChildItem -Path (Join-Path $PSScriptRoot 'functions\SQL') -Include *.ps1 -Recurse ) -$sync = @( Get-ChildItem -Path (Join-Path $PSScriptRoot 'functions\Sync') -Include *.ps1 -Recurse ) -$automation = @( Get-ChildItem -Path (Join-Path $PSScriptRoot 'functions\Automation') -Include *.ps1 -Recurse ) - -foreach ($import in @($base + $generic + $specific + $SQL + $sync + $automation)) { - . $import.fullname -} -``` - -### 2. Global State Management - -#### Graph API State -- `$Global:AccessToken` - Current OAuth access token -- `$Global:ClientId` - Azure AD application client ID -- `$Global:ClientSecret` - Application secret (for service principal auth) -- `$Global:TenantId` - Azure AD tenant ID -- `$Global:RefreshToken` - Refresh token (for interactive auth) -- `$Global:DebugMode` - Debug flag ('T', 'G', 'P', 'D' or combinations) +--- -#### SQL State -- `$Global:FGSQLConnectionString` - SQL connection string (legacy — Docker uses `DATABASE_URL` env var for PostgreSQL) -- `$Global:FGSQLServerName` - Connected server name (legacy) -- `$Global:FGSQLDatabaseName` - Connected database name (legacy) -### 3. `principalType` Conventions +## Repository Setup (One-Time) -The `Principals.principalType` column is NVARCHAR(50). Use these values consistently across all sync and scoring functions: +### GitHub Actions Secrets -| Value | Description | Source | -|---|---|---| -| `User` | Interactive human user account | `Sync-FGPrincipal`, `Sync-FGCSVPrincipal` | -| `ServicePrincipal` | App registration service principal | `Sync-FGServicePrincipal` | -| `ManagedIdentity` | Azure resource-attached managed identity (system or user-assigned) | `Sync-FGServicePrincipal` | -| `WorkloadIdentity` | Federated credential identity (GitHub Actions, AKS workloads) | `Sync-FGServicePrincipal` / CSV import | -| `AIAgent` | Explicitly identified AI agent (Copilot Studio, Azure OpenAI, custom) | `Sync-FGServicePrincipal` auto-detection, CSV import | -| `ExternalUser` | Guest / B2B account from another tenant | CSV import | -| `SharedMailbox` | Shared mailbox or room/equipment account | CSV import | - -**Detection rules in `Sync-FGServicePrincipal`:** -1. `servicePrincipalType = 'ManagedIdentity'` → `ManagedIdentity` -2. Tags contain `CopilotStudio`, `PowerVirtualAgents`, `AzureOpenAI`, or `CognitiveServices` → `AIAgent` -3. `displayName` matches AI patterns (copilot, openai, bot, azure-ai, gpt, etc.) → `AIAgent` -4. Custom `-AINamePatterns` provided → `AIAgent` -5. Default → `ServicePrincipal` - -**Risk scoring behavior by principalType:** -- `User` → full stale sign-in, never-signed-in, guest checks; user classifiers apply -- `ServicePrincipal` / `ManagedIdentity` / `WorkloadIdentity` / `AIAgent` → non-human structural signals (no stale sign-in); agent classifiers apply -- All types → direct classifier matching, membership analysis, propagation - -### 4. Function Naming Convention - -- **Prefix:** `FG` (FortigiGraph) for all exported functions -- **Aliases:** Each function has an alias without the `FG` prefix (e.g., `Get-FGGroup` -> `Get-Group`) -- **Verbs:** Standard PowerShell verbs (Get, New, Set, Add, Remove, Confirm, Invoke, Connect, Test, Initialize, Sync, Clear, Start) -- **Pattern:** `Verb-FGNoun` - -### 4. Config File Pattern - -The config file (`Config/tenantname.json.template`) drives all operations: - -```powershell -# All major functions support -ConfigFile (only relevant when running crawler scripts outside Docker) -Get-FGAccessToken -ConfigFile .\Config\mycompany.json -.\tools\crawlers\entra-id\Start-EntraIDCrawler.ps1 ` - -ApiBaseUrl http://localhost:3001/api ` - -ApiKey $apiKey ` - -ConfigFile .\Config\mycompany.json -``` +| Secret | Required scopes | Purpose | +|--------|----------------|---------| +| `VERSION_BUMP_PAT` | `repo` (includes `contents:write`) | Lets `bump-version.yml`, `cut-release.yml`, and `cut-hotfix.yml` push tags and commits to `main`. | -### 5. The SQL Helper Pattern: `Invoke-FGSQLCommand` +### Branch Protection -**Critical design pattern.** All SQL functions delegate connection lifecycle to this helper: +Run once after repo creation (requires `gh` CLI authenticated as admin): -```powershell -Invoke-FGSQLCommand -ScriptBlock { - param($connection) - $cmd = $connection.CreateCommand() - $cmd.CommandText = "SELECT COUNT(*) FROM Users" - return $cmd.ExecuteScalar() -} +```bash +bash tools/setup-branch-protection.sh Fortigi/IdentityAtlas ``` -### 6. Authentication in Start-FGSync - -`Start-FGSync` always gets a fresh token at the start of every sync run. This prevents stale token issues when switching between app registrations or when permissions have been updated. - -### 7. Pagination Handling (Graph API) - -All GET requests automatically handle Microsoft Graph pagination via `Invoke-FGGetRequest`. - -### 8. Debug Mode - -Debug output controlled via `$Global:DebugMode`: -- `'T'` - Token operations -- `'G'` - GET requests -- `'P'` - POST/PATCH requests -- `'D'` - DELETE requests -- Combine: `'GP'`, `'TPD'`, etc. - -## Key Conventions for AI Assistants - -### 1. File Organization - -**All function files live under `Functions/`:** +This sets: `main` — PR required (1 approval), `PR Summary` check required, admins bypass. -| Folder | Purpose | Example | -|--------|---------|---------| -| **Functions/Base/** | Core HTTP operations, authentication | `Invoke-FGGetRequest.ps1`, `Get-FGAccessToken.ps1` | -| **Functions/Generic/** | Direct Microsoft Graph API wrappers (1:1 mapping) | `Get-FGUser.ps1`, `Get-FGGroup.ps1` | -| **Functions/Sync/** | Data sync operations | `Sync-FGUser.ps1`, `Start-FGSync.ps1` | -| **Functions/Specific/** | Business logic combining multiple functions | `Confirm-FGGroup.ps1` | - -**File naming:** `Verb-FGNoun.ps1` (e.g., `Get-FGGroupMember.ps1`) - -### 2. Function Structure Templates - -#### Graph API Function Template - -```powershell -function Get-FGResource { - [alias("Get-Resource")] - [cmdletbinding()] - Param( - [Parameter(Mandatory = $false)] - [string]$Id, - [Parameter(Mandatory = $false)] - [string]$Filter - ) +--- - If ($Id) { - $URI = "https://graph.microsoft.com/beta/resources/$Id" - } ElseIf ($Filter) { - $URI = "https://graph.microsoft.com/beta/resources?`$filter=$Filter" - } Else { - $URI = "https://graph.microsoft.com/beta/resources" - } +## Development Workflow - $ReturnValue = Invoke-FGGetRequest -URI $URI - return $ReturnValue -} -``` +### Starting New Work -#### SQL Function Template - -```powershell -function Get-FGSQLResource { - [CmdletBinding()] - Param( - [Parameter(Mandatory = $false)] - [string]$Filter - ) - - Invoke-FGSQLCommand -ScriptBlock { - param($connection) - $cmd = $connection.CreateCommand() - $cmd.CommandText = "SELECT * FROM dbo.Resources WHERE Name = @Name" - $cmd.Parameters.AddWithValue("@Name", $ResourceName) - $reader = $cmd.ExecuteReader() - # Process results... - return $results - } -} +**Feature (not yet released):** +```bash +git checkout main && git pull +git checkout -b feature/ ``` -### 3. Important Rules - -**DO:** -- Follow existing naming conventions (`Verb-FGNoun`) -- Add aliases without `FG` prefix -- Use `Invoke-FG*Request` functions (never call `Invoke-RestMethod` directly for Graph) -- Use `Invoke-FGSQLCommand` helper for all SQL operations -- Use `/beta` endpoint unless told otherwise -- Place one function per file under `Functions/` -- Use `[cmdletbinding()]` for all functions -- Use color-coded Write-Host for user feedback (Green=success, Yellow=warning, Cyan=progress, Red=error) -- Use `-ErrorAction Stop` with try/catch for Azure operations that must succeed before continuing -- Return raw Graph objects (don't transform) - -**DON'T:** -- Don't call `Invoke-RestMethod` directly for Graph (use wrappers) -- Don't manage SQL connections manually (use `Invoke-FGSQLCommand`) -- Don't hardcode credentials or tokens -- Don't create multi-function files -- Don't use `Write-Output` (use `return` directly) -- Don't add comments in Dutch (use English only) -- Don't commit test configuration files (protected by .gitignore) -- Don't modify database schema manually — use migration files in `app/api/src/db/migrations/` -- Don't commit or push a fix without first testing it locally against the running Docker stack (see below) - -### 3a. Always Test Locally Before Committing - -After any change to the API, rebuild the container and verify the fix before touching git: - +**Pre-release bugfix:** ```bash -docker compose build web && docker compose up -d web -# then hit a representative endpoint, e.g.: -curl -s -X POST http://localhost:3001/api/ingest/contexts \ - -H "Authorization: Bearer " -H "Content-Type: application/json" \ - -d '{"records":[{"id":"...","contextType":"Department","displayName":"Test","systemId":1}],"syncMode":"full","systemId":1}' +git checkout main && git pull +git checkout -b bugfixes/ ``` -Only proceed to branch/commit/push once the endpoint returns a 2xx response. The prod compose file (`docker-compose.prod.yml`) uses a pre-built image from ghcr.io — changes to source files have no effect until the image is rebuilt with `docker compose build`. - -### 4. No Duplicate Code - -Before writing any utility function, helper, constant, or component — **search first**. - -**PowerShell:** Check `Functions/` for an existing function that does the same thing. If it exists, call it. If it almost fits, extend it rather than copy it. - -**React/JS:** Check `app/ui/src/utils/` and `app/ui/src/hooks/` before writing any helper inline in a component. Known shared utilities: -- `utils/formatters.js` — `formatDate`, `formatValue`, `computeHistoryDiffs`, `friendlyLabel` -- `utils/tierStyles.js` — `TIER_STYLES` (risk tier colors) and `tierClass(tier)` helper -- `utils/colors.js` — `TAG_COLORS` and AP color palette -- `utils/exportToExcel.js` / `utils/exportAccessPackagesToExcel.js` — Excel export logic -- `hooks/useEntityPage.js` — search, filter, tags, and pagination for list pages -- `hooks/useDebouncedValue.js` — `useDebouncedValue(value, delay)` hook -- `components/ConfidenceBar.jsx` — correlation confidence bar -- `components/DetailSection.jsx` — `Section` and `CollapsibleSection` used by detail pages - -If the same logic already exists in one file and you are about to write it in a second file, stop and extract it to a shared location instead. Three or more files with the same code is a mandatory extraction — don't leave it for later. - -### 5. When Extending the Module - -1. **Check if function already exists:** Search `Functions/` folders first -2. **Determine correct location:** - - Direct Graph API call -> `Functions/Generic/` - - SQL operation -> `Functions/SQL/` - - Data sync operation -> `Functions/Sync/` - - Risk scoring / LLM / clustering -> `Functions/RiskScoring/` - - Combines multiple operations -> `Functions/Specific/` - - Core HTTP/auth -> `Functions/Base/` (rarely needed) -3. **Follow the pattern:** Look at similar existing functions -4. **Update module version** after making changes - -## Graph API Permissions - -The Crawlers wizard validates these permissions on the App Registration during setup: - -| Permission | ID | Purpose | -|---|---|---| -| `User.Read.All` | `df021288-bdef-4463-88db-98f22de89214` | Read all users | -| `Group.Read.All` | `5b567255-7703-4780-807c-7be8301ae99b` | Read all groups | -| `GroupMember.Read.All` | `98830695-27a2-44f7-8c18-0c3ebc9698f6` | Read group memberships | -| `Directory.Read.All` | `7ab1d382-f21e-4acd-a863-ba3e13f7da61` | Read directory data | -| `Application.Read.All` | `9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30` | Read service principals + app role assignments (Sync-FGEntraAppRoleAssignment) | -| `PrivilegedEligibilitySchedule.Read.AzureADGroup` | `b3a539c9-59be-4c8d-b62c-11ae8c4f2a37` | Read PIM group eligibility schedules (Sync-FGGroupEligibleMember) | -| `EntitlementManagement.Read.All` | `c74fd47d-ed3c-45c3-9a9e-b8676de685d2` | Read access packages | -| `AccessReview.Read.All` | `d07a8cc0-3d51-4b77-b3b0-32704d1f69fa` | Read access reviews | -| `AuditLog.Read.All` | `b0afded3-3588-46d8-8b3d-9842eff778da` | Read audit/sign-in data | - -## Analytical Views - -### Group Membership Views (via `Initialize-FGGroupMembershipViews`) - -- `vw_GraphGroupMembersRecursive` - Calculates ALL memberships (direct + indirect) with paths using recursive CTE -- `vw_UserPermissionAssignments` - All membership types as separate rows: Owner, Direct, Indirect, Eligible + `managedByAccessPackage` (BIT). A user can have multiple rows per group (e.g. Direct + Owner) — no deduplication, so the UI can show all types. - -### Access Package Views (via `Initialize-FGAccessPackageViews`) - -- `vw_UserPermissionAssignmentViaAccessPackage` - User permissions via access packages -- `vw_DirectGroupMemberships` - Direct group memberships -- `vw_DirectGroupOwnerships` - Direct group ownerships -- `vw_UnmanagedPermissions` - IST vs SOLL gaps -- `vw_AccessPackageAssignmentDetails` - Assignment details -- `vw_AccessPackageLastReview` - Last review per package -- `vw_ApprovedRequestTimeline` - Approval times with response buckets -- `vw_DeniedRequestTimeline` - Denied request analysis -- `vw_PendingRequestTimeline` - Aging pending requests -- `vw_RequestResponseMetrics` - Aggregate approval statistics - -## Development Workflow - -### Starting New Work - +**Hotfix (bug in a released version):** ```bash -git checkout main && git pull -git checkout -b feature/ # e.g. feature/risk-score-export -# or -git checkout -b bugfixes/ # e.g. bugfixes/fix-login-redirect +git checkout -b bugfixes/ v5.2.0 # branch from the release tag, NOT main +git push origin bugfixes/ +# Then run Actions → Cut Hotfix with the branch name and new version +# After the hotfix ships, cherry-pick the fix to main via a separate PR ``` ### Making Changes 1. **Create/Edit** the relevant files -2. **Test locally** against the running Docker stack (`docker compose build web && docker compose up -d web`) -3. **Add bullets to `changes/.md`** describing the functional change (create the file if it doesn't exist — do NOT edit `CHANGES.md` directly) +2. **Test locally** against the running Docker stack +3. **Add bullets to `changes/.md`** (create if it doesn't exist — do NOT edit `CHANGES.md`) 4. **Commit** with descriptive messages ### Stacked PRs (preferred workflow) -Break features and auto-fixes into a **stack of small, focused PRs** rather than one large PR. Each step gets its own branch targeting the previous branch in the stack. - -> **GitHub native stacking (private preview, April 2026):** GitHub is rolling out a native `gh stack` extension that improves on the manual pattern below in four ways: (1) `gh stack submit` creates all PRs in the stack at once; (2) `gh stack sync` auto-rebases the entire stack after a bottom PR merges — eliminating the manual `gh pr edit --base` step; (3) a visual stack map appears in every PR so reviewers can navigate the chain; (4) "Direct merge" merges a PR and all its unmerged dependencies in one click. Sign up at `gh.io/stacksbeta`. Once available, prefer `gh stack` commands over the manual pattern below. - -**Manual pattern (use until `gh stack` is available):** +Break features into a stack of small, focused PRs. Each step gets its own branch targeting the previous branch in the stack. ```bash # First slice — targets main git checkout main && git pull git checkout -b feature/foo-step-1 -# ... make changes, commit ... gh pr create --base main --title "step 1: ..." # Second slice — stacked on top of step 1 git checkout -b feature/foo-step-2 -# ... make changes, commit ... gh pr create --base feature/foo-step-1 --title "step 2: ..." ``` When a bottom PR merges, retarget the next one: `gh pr edit --base main`. -### Merging Feature/Bugfixes → Main (via PR) +### Merging to Main -1. Open PR from `feature/` or `bugfixes/` into `main` (or into the previous stack branch) +1. Open PR from `feature/` or `bugfixes/` into `main` 2. Use the fragment content from `changes/.md` as the PR description 3. Requires 1 approval — merge when CI passes +4. After merge: `bump-version.yml` increments Minor + timestamp; `docker-publish.yml` pushes `:edge` -### Version Updates +### Cutting a Release -Version format: `Major.Minor.yyyyMMdd.HHmm` (e.g., `2.5.20260317.1430`) +1. Go to **Actions → Cut Release → Run workflow** +2. Enter the version: `Major.Minor.Patch` (e.g. `5.2.0`) +3. The workflow creates tag `v5.2.0` on the current `main` HEAD +4. `docker-publish.yml` pushes `:latest` + `:5.2.0.0` -See the **Branching & Versioning Strategy** section above for the full scheme. The Docker images are built and pushed by the `docker-publish.yml` GitHub Action on merge to `main`, tagged with both `latest` and the module version. - -## User Workflow (Getting Started) - -The recommended flow for new users: +### Hotfix Releases ```bash -# 1. Download the production compose file -curl -O https://raw.githubusercontent.com/Fortigi/IdentityAtlas/main/docker-compose.prod.yml - -# 2. Start the stack -docker compose -f docker-compose.prod.yml up -d - -# 3. Open http://localhost:3001 → go to Admin → Crawlers, then click "Load Demo Data" or "Add Crawler" to connect Entra ID -# 4. Configure crawlers via the in-browser wizard (Admin → Crawlers → Add Crawler) +git checkout -b bugfixes/fix-foo v5.2.0 +git push origin bugfixes/fix-foo ``` -## Codebase Maintenance Analysis (Feb 2026) - -> **This section documents known technical debt, bugs, and improvement opportunities discovered during a comprehensive code review. Use this as a backlog for maintenance sprints.** - -### ~~Critical Bugs (Must Fix)~~ RESOLVED (March 2026) - -All critical bugs fixed in maintenance sprint: - -| # | File | Issue | Status | -|---|------|-------|--------| -| ~~1~~ | ~~`Confirm-FGUser.ps1`~~ | ~~`$Group.count` → `$User.count`~~ | **RESOLVED** | -| ~~2~~ | ~~`Confirm-FGAccessPackagePolicy.ps1`~~ | ~~Copy-paste: checked `accessPackageId` instead of `displayName`~~ | **RESOLVED** | -| ~~3~~ | ~~`Confirm-FGAccessPackage.ps1`~~ | ~~Undefined `$AccessPackageName` → `$DisplayName`~~ | **RESOLVED** | -| ~~4~~ | ~~`Get-FGAccessPackagesAssignments.ps1`~~ | ~~Undefined `$id` → `$AccessPackageID`~~ | **RESOLVED** | -| ~~5~~ | ~~`Remove-FGAccessPackage.ps1`~~ | ~~Plural/singular mismatch in loop~~ | **RESOLVED** | -| ~~6~~ | ~~`Get-FGUserMail.ps1`~~ | ~~Checked `$MailFolder` instead of `$MailFolderId`~~ | **RESOLVED** | -| ~~7~~ | ~~`Get-FGApplicationExtensionProperty.ps1`~~ | ~~Naming convention reversed~~ | **RESOLVED** | -| ~~8~~ | ~~`Sync-FGGroupTransitiveMember.ps1`~~ | ~~Function removed (replaced by SQL view)~~ | **RESOLVED** | -| ~~9~~ | ~~`Use-FGExistingMSALToken.ps1`~~ | ~~Called `Get-AccessTokenDetail` instead of `Get-FGAccessTokenDetail`~~ | **RESOLVED** | - -Also fixed in same sprint: -- ~~`Invoke-FGPutRequest.ps1` debug output said "PatchRequest"~~ → **RESOLVED** -- ~~`Invoke-FGPutRequest.ps1` used `$ReturnValue += $Result` on undefined~~ → **RESOLVED** (now uses `= $Result`) -- ~~"cataloge" typo in 3 Confirm-FG* functions~~ → **RESOLVED** (fixed to "catalog") -- ~~"More then one" in 8 Confirm-FG* functions~~ → **RESOLVED** (fixed to "More than one") -- ~~Dutch comment in `Confirm-FGGroup.ps1`~~ → **RESOLVED** (translated to English) -- ~~SQL injection in `riskScores.js` hasRiskColumns()~~ → **RESOLVED** (parameterized + whitelist) - -### ~~High-Priority Refactoring: DRY Violations in Base HTTP Functions~~ RESOLVED - -**RESOLVED:** `Update-FGAccessTokenIfExpired` extracted to `Functions/Base/Update-FGAccessTokenIfExpired.ps1` and all 6 HTTP functions refactored to use it. Remaining opportunities: -- Debug output blocks (~8 lines each) → Extract to `Write-FGDebugMessage` -- Response value extraction (~6 lines each) → Extract to `Get-FGResponseValue` - -### ~~High-Priority Refactoring: Sync Function Duplication~~ RESOLVED - -**RESOLVED:** Two helpers extracted to `Functions/Sync/`: -- `Initialize-FGSyncTable.ps1` — handles table existence, schema evolution, recreation -- `New-FGDataTableFromGraphObjects.ps1` — builds DataTables with type conversion and custom value resolvers - -All 9 sync functions refactored to use these helpers. Remaining opportunity: -- **Group fetching** duplicated across 4 group-based syncs (~40 lines × 4). Create `Get-FGGroupsForSync` helper. - -### High-Priority: Massive Functions to Break Down - -### High-Priority: Generic Functions Consolidation - -**"All" and "AllToFile" function pairs** have 95%+ duplication: -- `Get-FGGroupMemberAll.ps1` / `Get-FGGroupMemberAllToFile.ps1` -- `Get-FGGroupTransitiveMemberAll.ps1` / `Get-FGGroupTransitiveMemberAllToFile.ps1` - -**Action:** Merge each pair into one function with optional `-OutputFile` parameter. The 52-line JSON restructuring routine is identical in both "ToFile" functions — extract to a shared helper. - -**URI filter building** is duplicated across 6+ Get functions (Get-FGUser, Get-FGGroup, Get-FGApplication, Get-FGServicePrincipal, Get-FGCatalog, Get-FGDevice). Consider a shared `Build-FGGraphUri` helper. - -**Missing `[cmdletbinding()]`** on: `Get-FGGroupMemberAll`, `Get-FGGroupMemberAllToFile`, `Get-FGGroupTransitiveMemberAll`, `Get-FGGroupTransitiveMemberAllToFile`. - -### Medium-Priority: SQL Function Improvements - -~~**SQL injection risks** (parameterize these):~~ **RESOLVED** -- ~~`Get-FGSQLTable.ps1`: Schema/pattern in WHERE via string interpolation~~ → parameterized via `Invoke-FGSQLCommand` -- ~~`Get-FGSyncLog.ps1`: SyncType/Status in WHERE via string interpolation~~ → parameterized via `Invoke-FGSQLCommand` -- ~~`New-FGSQLReadOnlyUser.ps1`: Password embedded directly in SQL string~~ → username validated with `[a-zA-Z0-9_]` regex, password escaped - -**Connection management inconsistency** — 2 functions bypass `Invoke-FGSQLCommand`: -- `Write-FGSyncLog.ps1` (lines 98-172): Manual connection management -- `New-FGSQLReadOnlyUser.ps1` (lines 103-141): Manual connection management +Then: **Actions → Cut Hotfix** with branch name and new version (e.g. `5.2.1`). Cherry-pick to `main` afterward. -**Extract shared SQL helpers:** -- `Set-FGSQLTableVersioning -Enable/-Disable` (duplicated in `Add-FGSQLTableColumn` and `Clear-FGSQLTable`) -- `ConvertTo-FGSQLType` / `ConvertTo-FGDotNetType` (duplicated in `Invoke-FGSQLBulkDelete` and `Invoke-FGSQLBulkMerge`) -- Table name parsing with schema (duplicated in `Clear-FGSQLTable` and `Get-FGSQLTableSchema`) - -### Medium-Priority: Sync Performance & Reliability - -**Missing batching options** — these load all data into memory (risk `OutOfMemoryException` for large tenants): -- `Sync-FGGroupOwner` — no batching option -- `Sync-FGUser` / `Sync-FGGroup` — no batching for very large tenants - -**Retry logic** only exists in `Sync-FGAccessPackageResourceRoleScope`. Move to `Invoke-FGGetRequest` or create `Invoke-FGGetRequestWithRetry` so all sync functions benefit from transient error handling (429, 503, 504). - -**Deduplication** only in some sync functions (`Sync-FGAccessPackageAssignment`, `Sync-FGAccessPackageAssignmentRequest`). Add to `Sync-FGUser`, `Sync-FGGroup`, `Sync-FGGroupMember` to prevent MERGE failures. - -**GC calls** only in 2 sync functions. Standardize `[System.GC]::Collect()` every 50 iterations in all batching loops. - -**Token refresh during long syncs:** `Start-FGSync` gets a token once at start. For 2+ hour syncs, tokens expire (~1 hour). The token check in `Invoke-FGGetRequest` should handle this, but verify it works correctly within runspaces where global state is copied. - -**No dependency enforcement in Start-FGSync:** GroupMembers can start before Groups completes. Consider adding sync phases (Phase 1: Users+Groups, Phase 2: memberships, Phase 3: access packages, Phase 4: materialized views). - -### Medium-Priority: Deprecated Patterns - -**OAuth2 v1 endpoints** used in 4 files (v1 being deprecated by Microsoft): -- `Get-FGAccessToken.ps1` line 117: `/oauth2/token` -- `Get-FGAccessTokenInteractive.ps1` lines 23, 32 -- `Get-FGAccessTokenWithRefreshToken.ps1` line 21 - -**Action:** Migrate to `/oauth2/v2.0/token` endpoint. - -### Medium-Priority: Specific/Helper Cleanup - -~~**Typos**~~ → **RESOLVED** (March 2026): "cataloge" → "catalog", "More then one" → "More than one", Dutch comment translated. - -~~**Duplicate Azure REST helpers in New-FGUI / Remove-FGUI**~~ → **RESOLVED** (April 2026): all Azure deployment functions removed; project is Docker-only. - -**Confirm-FGGroupMember / Confirm-FGNotGroupMember** share 40+ lines of identical member resolution logic. Extract to `Resolve-FGMemberObjectIds`. - -### UI Backend Improvements - -**Security (Critical):** -- ~~`index.js` line 14: `app.use(cors())` allows ALL origins~~ → **RESOLVED:** CORS now configured with `ALLOWED_ORIGINS` env var; production blocks cross-origin by default -- ~~No rate limiting on any endpoint~~ → **RESOLVED:** Added `express-rate-limit` on pre-auth endpoints (30 req/min per IP); `helmet` for security headers (CSP, HSTS, X-Frame-Options, Referrer-Policy); `express.json({ limit: '100kb' })` body size cap; startup warning when `AUTH_ENABLED` not set in production; `/api/auth-config` no longer confirms auth is disabled -- ~~Error responses leak SQL schema info (table names, column names)~~ → **RESOLVED** (March 2026): All `console.error` calls now use `err.message` instead of full `err` objects; error responses return generic messages -- No audit logging for mutations — log user identity + changes for compliance -- ~~Auth middleware (`auth.js`) doesn't validate token scopes/roles~~ → **RESOLVED:** Added tenant ID validation and optional role-based access control via `AUTH_REQUIRED_ROLES` env var -- ~~Bulk operations (`/tags/:id/assign-by-filter`) have no row limit~~ → **RESOLVED:** Added `TOP 50000` safety cap; hex color validation (`/^#[0-9a-fA-F]{6}$/`) on tag and category create/update endpoints -- ~~SQL injection via string interpolation of offset/limit in `riskScores.js` and `identities.js`~~ → **RESOLVED** (March 2026): Parameterized with `@offset`/`@limit` inputs -- ~~Missing `parseInt` validation across tag/category routes~~ → **RESOLVED** (March 2026): Added `isNaN()` checks with 400 responses; radix 10 on all `parseInt` calls -- ~~Unbounded `entityIds` array in tag assign/unassign~~ → **RESOLVED** (March 2026): Capped at 500 IDs per request -- ~~`assignedBy` in cluster owner derived from request body~~ → **RESOLVED** (March 2026): Now derived from `req.user` (authenticated identity) -- ~~Missing input length limits on identity notes/reason fields~~ → **RESOLVED** (March 2026): Notes capped at 2000 chars, reason at 500 chars -- ~~Column names in `columnCache.js` not validated against injection~~ → **RESOLVED** (March 2026): Added `SAFE_IDENT_RE` regex validation for column and table names - -**~~Performance (Critical):~~** **RESOLVED** -- ~~`tags.js` lines 194-206: N+1 query in tag assignment loop — batch into single INSERT~~ → batched into single parameterized INSERT with NOT EXISTS -- ~~`tags.js` lines 226-231: Same N+1 pattern in unassign loop~~ → batched into single DELETE with IN clause -- ~~`tags.js` line 98: Subquery COUNT per row — use LEFT JOIN + GROUP BY instead~~ → replaced with LEFT JOIN + GROUP BY (also fixed same pattern in `categories.js` line 50) -- ~~Column discovery runs on every request — add TTL-based cache (5 min)~~ → extracted to `db/columnCache.js` with 5-minute TTL - -**Code Quality:** -- ~~Column discovery logic duplicated between `permissions.js` and `tags.js`~~ → **RESOLVED:** extracted to shared `db/columnCache.js` with TTL cache -- `ensureTagTables` / `ensureCategoryTables` — extract to shared `ensureTable` utility -- Pagination parameter parsing duplicated across routes -- ~~`db/connection.js`: No pool error handling, no graceful shutdown, no reconnect logic~~ → **RESOLVED:** Added pool error listener with auto-reconnect, `closePool()` export, and graceful SIGTERM/SIGINT shutdown in `index.js` -- Inconsistent response formats across endpoints — standardize to `{ data, total, ... }` - -### UI Frontend Improvements - -**Performance:** -- ~~No code splitting — all 5 pages bundled eagerly~~ → **RESOLVED:** All 5 pages use `React.lazy()` + `` for route-based code splitting -- ~~ExcelJS (~200KB) loaded on every page~~ → **RESOLVED:** Dynamic `import()` in `handleExportExcel` — ExcelJS only loads when user clicks Export -- ~~@dnd-kit (~110KB) loaded even when drag not active — lazy-load~~ → **RESOLVED:** Extracted to `SortableMatrixBody.jsx` (separate chunk, ~60KB), dynamically imported. MatrixView renders static rows immediately, upgrades to sortable when chunk loads -- ~~No virtual scrolling in matrix — becomes slow with 100+ groups~~ → **RESOLVED:** `@tanstack/react-virtual` virtualizes table rows (overscan=20). During drag, virtualization is disabled so all rows are in the DOM for accurate drop positioning -- ~~`MatrixCell.jsx` memo comparison (line 80) missing `apNames` prop — stale renders possible~~ → **RESOLVED:** Added `apNames` to memo comparison - -**Code Duplication:** -- ~~`UsersPage.jsx` / `GroupsPage.jsx`: 95% identical (565 lines each)~~ → **RESOLVED:** Extracted `useEntityPage` hook to `hooks/useEntityPage.js`; both pages reduced from ~565 to ~270 lines -- Tag operation handlers duplicated in AccessPackagesPage — could use `useEntityPage` hook too -- `TAG_COLORS` array defined 3 times — move to shared constants -- Search debounce pattern repeated in 4 places — extract `useDebouncedValue` hook -- Pagination UI duplicated in 3 pages — extract `PaginationControls` component -- `AP_COLORS` array duplicated in `MatrixColumnHeaders.jsx` and `exportToExcel.js` - -**Architecture:** -- `MatrixView.jsx` (584 lines) handles data transformation + row reordering + Excel export + rendering — split into data hook + presentation -- App.jsx passes 16 props to MatrixView — consider Context or custom hook -- Prop drilling: MatrixView (36 props) → MatrixToolbar (21 props) → FilterBar (7 props) - -**Accessibility:** -- Filter dropdowns use `
` instead of `
+ ); +} + +export default function AboutPage() { + return ( +
+ {/* Header */} +
+

About Identity Atlas

+

+ Identity Atlas is an open-source role-mining and identity governance platform built by{' '} + Fortigi. + It pulls authorization data from Microsoft Graph and other systems into a PostgreSQL database and + surfaces it through a React role-mining UI. +

+ +
+ + {/* License */} +
+

License

+
+          {MIT_LICENSE}
+        
+
+ + {/* Software BOM */} +
+

Software Bill of Materials

+

+ All direct dependencies use permissive open-source licenses (MIT, Apache 2.0, or PostgreSQL License). +

+
+ {SBOM_SECTIONS.map((section) => ( +
+

+ {section.title} +

+ +
+ ))} +
+
+
+ ); +} diff --git a/app/ui/src/components/AccessPackageDetailPage.jsx b/app/ui/src/components/AccessPackageDetailPage.jsx index 635451a36..4187a59b5 100644 --- a/app/ui/src/components/AccessPackageDetailPage.jsx +++ b/app/ui/src/components/AccessPackageDetailPage.jsx @@ -1,92 +1,40 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useAuth } from '../auth/AuthGate'; import RiskScoreSection from './RiskScoreSection'; -import { formatDate, formatValue, computeHistoryDiffs, friendlyLabel } from '../utils/formatters'; -import { Section, CollapsibleSection } from './DetailSection'; +import { formatDate, computeHistoryDiffs, friendlyLabel } from '../utils/formatters'; +import { CollapsibleSection } from './DetailSection'; +import EntityGraph from './EntityGraph'; +import EntityDetailLayout, { AttributesTable, buildAttributeEntries } from './EntityDetailLayout'; +import ExpandedItemsList from './ExpandedItemsList'; +import RecentChangesSection from './RecentChangesSection'; +import useExpandableGraph from '../hooks/useExpandableGraph'; +import useRecentChanges from '../hooks/useRecentChanges'; +import { getRootNodes } from './entityGraphShape'; const HEADER_FIELDS = ['catalogName', 'catalogId', 'description']; -const HIDDEN_FIELDS = new Set(['displayName', ...HEADER_FIELDS, 'ValidFrom', 'ValidTo']); - -const SCOPE_LABELS = { - allMemberUsers: 'All member users', - allDirectoryUsers: 'All directory users', - specificDirectoryUsers: 'Specific directory users', - allDirectoryServicePrincipals: 'All service principals', - specificDirectoryServicePrincipals: 'Specific service principals', - specificConnectedOrganizationUsers: 'Specific connected org users', - allConfiguredConnectedOrganizationUsers: 'All configured connected org users', - allExternalUsers: 'All external users', - notSpecified: 'Not specified', -}; - -function formatScope(val) { - if (!val) return '\u2014'; - return SCOPE_LABELS[val] || val; -} - -const DECISION_STYLES = { - Approve: 'bg-green-100 text-green-800', - Deny: 'bg-red-100 text-red-800', - DontKnow: 'bg-yellow-100 text-yellow-800', - NotReviewed: 'bg-gray-100 text-gray-600', -}; - -const DECISION_LABELS = { - Approve: 'Approved', - Deny: 'Denied', - DontKnow: 'Don\u2019t Know', - NotReviewed: 'Not Reviewed', -}; - -const REQUEST_STATE_STYLES = { - PendingApproval: 'bg-yellow-100 text-yellow-800', - Delivering: 'bg-blue-100 text-blue-800', - Accepted: 'bg-green-100 text-green-800', -}; +const HIDDEN_FIELDS = new Set([ + 'displayName', ...HEADER_FIELDS, 'ValidFrom', 'ValidTo', 'extendedAttributes', +]); const ASSIGNMENT_TYPE_STYLES = { - 'Auto-assigned': 'bg-green-100 text-green-800 border-green-200', - 'Request-based': 'bg-blue-100 text-blue-800 border-blue-200', - 'Request-based with auto-removal': 'bg-orange-100 text-orange-800 border-orange-200', - 'Both': 'bg-purple-100 text-purple-800 border-purple-200', + 'Auto-assigned': 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 border-green-200 dark:border-green-700', + 'Request-based': 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 border-blue-200 dark:border-blue-700', + 'Request-based with auto-removal': 'bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 border-orange-200 dark:border-orange-700', + 'Both': 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 border-purple-200 dark:border-purple-700', }; -export default function AccessPackageDetailPage({ accessPackageId, cachedData, onCacheData, onClose }) { +export default function AccessPackageDetailPage({ accessPackageId, cachedData, onCacheData, onClose, onOpenDetail }) { const { authFetch } = useAuth(); - // Core data (fast - attributes, counts) const [data, setData] = useState(cachedData?.core || null); const [loading, setLoading] = useState(!cachedData?.core); const [error, setError] = useState(null); - // Lazy-loaded sections - const [reviewsOpen, setReviewsOpen] = useState(false); - const [reviews, setReviews] = useState(cachedData?.reviews || null); - const [reviewsLoading, setReviewsLoading] = useState(false); - - const [requestsOpen, setRequestsOpen] = useState(false); - const [requests, setRequests] = useState(cachedData?.requests || null); - const [requestsLoading, setRequestsLoading] = useState(false); - - const [assignmentsOpen, setAssignmentsOpen] = useState(false); - const [assignments, setAssignments] = useState(cachedData?.assignments || null); - const [assignmentsLoading, setAssignmentsLoading] = useState(false); - - const [resourceRolesOpen, setResourceRolesOpen] = useState(false); - const [resourceRoles, setResourceRoles] = useState(cachedData?.resourceRoles || null); - const [resourceRolesLoading, setResourceRolesLoading] = useState(false); - - const [policiesOpen, setPoliciesOpen] = useState(false); - const [policies, setPolicies] = useState(cachedData?.policies || null); - const [policiesLoading, setPoliciesLoading] = useState(false); - const [historyOpen, setHistoryOpen] = useState(false); const [history, setHistory] = useState(cachedData?.history || null); const [historyLoading, setHistoryLoading] = useState(false); - const [riskData, setRiskData] = useState(null); - // Fetch risk score data useEffect(() => { (async () => { try { @@ -96,7 +44,6 @@ export default function AccessPackageDetailPage({ accessPackageId, cachedData, o })(); }, [authFetch, accessPackageId]); - // Fetch core data useEffect(() => { if (cachedData?.core) return; let cancelled = false; @@ -115,77 +62,6 @@ export default function AccessPackageDetailPage({ accessPackageId, cachedData, o return () => { cancelled = true; }; }, [accessPackageId, authFetch, cachedData?.core, onCacheData]); - // Lazy-load reviews - const loadReviews = useCallback(() => { - if (reviews) return; - setReviewsLoading(true); - authFetch(`/api/access-package/${encodeURIComponent(accessPackageId)}/reviews`) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => { - setReviews(d); - onCacheData?.(accessPackageId, 'access-package', { reviews: d }); - }) - .catch(() => setReviews([])) - .finally(() => setReviewsLoading(false)); - }, [accessPackageId, authFetch, reviews, onCacheData]); - - // Lazy-load requests - const loadRequests = useCallback(() => { - if (requests) return; - setRequestsLoading(true); - authFetch(`/api/access-package/${encodeURIComponent(accessPackageId)}/requests`) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => { - setRequests(d); - onCacheData?.(accessPackageId, 'access-package', { requests: d }); - }) - .catch(() => setRequests([])) - .finally(() => setRequestsLoading(false)); - }, [accessPackageId, authFetch, requests, onCacheData]); - - // Lazy-load assignments - const loadAssignments = useCallback(() => { - if (assignments) return; - setAssignmentsLoading(true); - authFetch(`/api/access-package/${encodeURIComponent(accessPackageId)}/assignments`) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => { - setAssignments(d); - onCacheData?.(accessPackageId, 'access-package', { assignments: d }); - }) - .catch(() => setAssignments([])) - .finally(() => setAssignmentsLoading(false)); - }, [accessPackageId, authFetch, assignments, onCacheData]); - - // Lazy-load resource roles - const loadResourceRoles = useCallback(() => { - if (resourceRoles) return; - setResourceRolesLoading(true); - authFetch(`/api/access-package/${encodeURIComponent(accessPackageId)}/resource-roles`) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => { - setResourceRoles(d); - onCacheData?.(accessPackageId, 'access-package', { resourceRoles: d }); - }) - .catch(() => setResourceRoles([])) - .finally(() => setResourceRolesLoading(false)); - }, [accessPackageId, authFetch, resourceRoles, onCacheData]); - - // Lazy-load policies - const loadPolicies = useCallback(() => { - if (policies) return; - setPoliciesLoading(true); - authFetch(`/api/access-package/${encodeURIComponent(accessPackageId)}/policies`) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => { - setPolicies(d); - onCacheData?.(accessPackageId, 'access-package', { policies: d }); - }) - .catch(() => setPolicies([])) - .finally(() => setPoliciesLoading(false)); - }, [accessPackageId, authFetch, policies, onCacheData]); - - // Lazy-load history const loadHistory = useCallback(() => { if (history) return; setHistoryLoading(true); @@ -199,127 +75,93 @@ export default function AccessPackageDetailPage({ accessPackageId, cachedData, o .finally(() => setHistoryLoading(false)); }, [accessPackageId, authFetch, history, onCacheData]); - const toggleReviews = useCallback(() => { - setReviewsOpen(prev => { if (!prev) loadReviews(); return !prev; }); - }, [loadReviews]); + const toggleHistory = useCallback(() => { + setHistoryOpen(prev => { if (!prev) loadHistory(); return !prev; }); + }, [loadHistory]); - const toggleRequests = useCallback(() => { - setRequestsOpen(prev => { if (!prev) loadRequests(); return !prev; }); - }, [loadRequests]); + const recent = useRecentChanges('access-package', accessPackageId, authFetch); - const toggleAssignments = useCallback(() => { - setAssignmentsOpen(prev => { if (!prev) loadAssignments(); return !prev; }); - }, [loadAssignments]); + const rootExtras = useMemo(() => ({ + catalogId: data?.attributes?.catalogId, + catalogName: data?.attributes?.catalogName, + recent, + }), [data, recent]); - const toggleResourceRoles = useCallback(() => { - setResourceRolesOpen(prev => { if (!prev) loadResourceRoles(); return !prev; }); - }, [loadResourceRoles]); + const rootNodes = useMemo(() => ( + data ? getRootNodes('access-package', data, rootExtras) : [] + ), [data, rootExtras]); - const togglePolicies = useCallback(() => { - setPoliciesOpen(prev => { if (!prev) loadPolicies(); return !prev; }); - }, [loadPolicies]); - - const toggleHistory = useCallback(() => { - setHistoryOpen(prev => { if (!prev) loadHistory(); return !prev; }); - }, [loadHistory]); + const graph = useExpandableGraph({ + rootEntityKind: 'access-package', + rootEntityId: accessPackageId, + rootExtras, + rootNodes, + authFetch, + }); if (loading) { - return
Loading business role details...
; + return
Loading business role details...
; } if (error) { return ( -
-

Error loading business role

-

{error}

+
+

Error loading business role

+

{error}

); } if (!data) return null; - const { attributes, assignmentCount, groupCount, reviewCount, pendingRequestCount, lastReviewDate, lastReviewedBy, historyCount, hasHistory, policyCount, assignmentType, category } = data; - const catalogName = attributes.catalogName || null; - const catalogId = attributes.catalogId || null; - const apDisplayName = attributes.displayName || ''; - const resolvedHistoryCount = history ? history.length : historyCount; - const otherAttributes = [['id', attributes.id], ...Object.entries(attributes).filter(([k]) => !HIDDEN_FIELDS.has(k) && k !== 'id')]; - const entraUrl = catalogId - ? `https://portal.azure.com/#view/Microsoft_Azure_ELMAdmin/EntitlementMenuBlade/~/overview/entitlementId/${accessPackageId.toLowerCase()}/catalogId/${catalogId}/catalogName/${encodeURIComponent(catalogName || '')}/entitlementName/${encodeURIComponent(apDisplayName)}` - : `https://entra.microsoft.com/#view/Microsoft_AAD_ERM/AccessPackageManagementMenuBlade/~/AccessPackageBladeOverview/accessPackageId/${encodeURIComponent(accessPackageId)}`; + const { attributes, historyCount, lastReviewDate, lastReviewedBy, assignmentType, category } = data; + const resolvedHistoryCount = history ? history.length : historyCount; + const attributeEntries = buildAttributeEntries( + attributes, + attributes.extendedAttributesParsed || (typeof attributes.extendedAttributes === 'object' ? attributes.extendedAttributes : null), + HIDDEN_FIELDS, + ); const historyDiffs = history ? computeHistoryDiffs(history) : []; return ( -
+
{/* Header */}
-
- AP -
+
AP
-

{attributes.displayName}

+

{attributes.displayName}

{assignmentType && ( {assignmentType} )} {category && ( - + {category.name} )}
- {catalogName && ( -

Catalog: {catalogName}

+ {attributes.catalogName && ( +

Catalog: {attributes.catalogName}

)}
{attributes.description && ( -

{attributes.description}

+

{attributes.description}

)} -
- {assignmentCount > 0 && {assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}} - {groupCount > 0 && ( - <> - {assignmentCount > 0 && |} - {groupCount} group{groupCount !== 1 ? 's' : ''} - - )} - {reviewCount > 0 && ( - <> - {(assignmentCount > 0 || groupCount > 0) && |} - {reviewCount} review{reviewCount !== 1 ? 's' : ''} - - )} - {requests && requests.length > 0 && ( - <> - | - {requests.length} pending request{requests.length !== 1 ? 's' : ''} - - )} -
{lastReviewDate && ( -
- Last Certification:{' '} +
+ Last Certification:{' '} {formatDate(lastReviewDate)} - {lastReviewedBy && by {lastReviewedBy}} + {lastReviewedBy && by {lastReviewedBy}}
)} - - Open in Entra ID - - - -
- {/* Risk Score */} - {riskData && } - - {/* Attributes */} -
- - - {otherAttributes.map(([key, val]) => ( - - - - - ))} - -
{friendlyLabel(key)}{formatValue(val)}
-
- - {/* Assignments (users assigned to this AP) */} - {assignmentCount > 0 && ( -
- - {assignments && assignments.length === 0 ? ( -

No assignments found

- ) : assignments && ( - - - - - - - - - - - {assignments.map(a => ( - - - - - - - ))} - -
UserStateStatusAssigned
-
{a.targetDisplayName || '\u2014'}
- {a.targetUPN &&
{a.targetUPN}
} -
- - {a.assignmentState || '\u2014'} - - {a.assignmentStatus || '\u2014'}{formatDate(a.assignedDate)}
- )} -
-
- )} - - {/* Resource Assignments (groups/resources in this AP) */} - {groupCount > 0 && ( -
- - {resourceRoles && resourceRoles.length === 0 ? ( -

No resource assignments found

- ) : resourceRoles && ( - - - - - - - - - - - {resourceRoles.map(rr => ( - - - - - - - ))} - -
ResourceRoleTypeAdded
-
{rr.groupDisplayName || rr.scopeDisplayName || '\u2014'}
- {rr.scopeOriginSystem &&
{rr.scopeOriginSystem}
} -
- - {rr.roleName || '\u2014'} - - {rr.resourceType || '\u2014'}{formatDate(rr.createdDateTime)}
- )} -
-
- )} + } + right={ +
+
+ + {graph.pathDepth > 0 && ( +
+ {graph.activeListLabel} + {' — '} + +
+ )} +
- {/* Assignment Policies */} - {policyCount > 0 && ( -
- - {policies && policies.length === 0 ? ( -

No policies found

- ) : policies && ( - - - - - - - - - - - {policies.map(p => ( - - - - - - - ))} - -
NameTypeScopeCreated
-
{p.displayName || '\u2014'}
- {p.description &&
{p.description}
} -
- - {p.hasAutoAddRule ? 'Auto-assigned' - : p.hasAutoRemoveRule ? 'Request-based with auto-removal' - : 'Request-based'} - - -
{formatScope(p.allowedTargetScope)}
- {p.autoAssignmentFilter && ( -
- {p.autoAssignmentFilter} -
- )} -
- {formatDate(p.createdDateTime)} -
+ {graph.pathDepth > 0 ? ( + + ) : ( +
+

Click a node in the graph to fan it out; click again to collapse.

+
)} -
-
- )} - - {/* Access Reviews */} -
- - {reviews && reviews.length === 0 ? ( -

No certification decisions found yet

- ) : reviews && ( - - - - - - - - - - - - - {reviews.map(r => ( - - - - - - - - - ))} - -
UserReviewed ByDecisionRecommendationDateStatus
{r.principalDisplayName || '\u2014'}{r.reviewedByDisplayName || '\u2014'} - - {DECISION_LABELS[r.decision] || r.decision || '\u2014'} - - {r.recommendation || '\u2014'}{formatDate(r.reviewedDateTime)}{r.reviewInstanceStatus || '\u2014'}
- )} -
-
- - {/* Pending Requests */} -
- - {requests && requests.length === 0 ? ( -

No pending requests

- ) : requests && ( - - - - - - - - - - - - - {requests.map(r => ( - - - - - - - - - ))} - -
RequestorTypeStateStatusCreatedJustification
-
{r.requestorDisplayName || '\u2014'}
- {r.requestorUPN &&
{r.requestorUPN}
} -
{r.requestType || '\u2014'} - - {r.requestState || '\u2014'} - - {r.requestStatus || '\u2014'}{formatDate(r.createdDateTime)} - {r.justification || '\u2014'} -
- )} -
-
+
+ } + > + {riskData && } + + - {/* Version History */} -
{historyDiffs.length === 0 ? ( -

No changes recorded

+

No changes recorded

) : ( - + {historyDiffs.map((diff, i) => ( - - + +
Date Changes
- {formatDate(diff.date)} -
{formatDate(diff.date)}
{diff.changes.map((c, j) => (
- {friendlyLabel(c.field)} - : - {c.from} - - {c.to} + {friendlyLabel(c.field)} + : + {c.from} + + {c.to}
))}
@@ -641,9 +257,7 @@ export default function AccessPackageDetailPage({ accessPackageId, cachedData, o
)}
-
+
); } - - diff --git a/app/ui/src/components/AccessPackagesPage.jsx b/app/ui/src/components/AccessPackagesPage.jsx index 0591ce470..289e89e28 100644 --- a/app/ui/src/components/AccessPackagesPage.jsx +++ b/app/ui/src/components/AccessPackagesPage.jsx @@ -252,8 +252,8 @@ export default function AccessPackagesPage({ onOpenDetail }) {
{/* Header */}
-

Business Roles

- {total.toLocaleString()} total +

Business Roles

+ {total.toLocaleString()} total @@ -311,14 +311,14 @@ export default function AccessPackagesPage({ onOpenDetail }) { {/* Create category form */} {showCreateCategory && ( -
+
setNewCategoryName(e.target.value)} onKeyDown={e => e.key === 'Enter' && createCategory()} placeholder="Category name..." - className="px-2 py-1 border border-gray-300 rounded text-sm w-48" + className="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm w-48 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500" autoFocus />
@@ -340,7 +340,7 @@ export default function AccessPackagesPage({ onOpenDetail }) { @@ -354,12 +354,12 @@ export default function AccessPackagesPage({ onOpenDetail }) { value={search} onChange={e => setSearch(e.target.value)} placeholder="Search by name or catalog..." - className="px-2 py-1 border border-gray-300 rounded text-xs w-56" + className="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-xs w-56 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500" /> {hasAnyFilter && ( <> -
+
@@ -381,13 +381,13 @@ export default function AccessPackagesPage({ onOpenDetail }) { {/* Action bar (visible when items selected) */} {selected.size > 0 && ( -
- {selected.size} selected -
+
+ {selected.size} selected +
toggleSort(col.key)} - className="text-left px-3 py-2 font-medium text-gray-700 cursor-pointer select-none hover:bg-gray-100" + className="text-left px-3 py-2 font-medium text-gray-700 dark:text-gray-300 cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-gray-700" > {col.label} {sortCol === col.key ? ( - {sortDir === 'asc' ? '\u25B2' : '\u25BC'} + {sortDir === 'asc' ? '▲' : '▼'} ) : ( - {'\u25B4'} + {'▴'} )} ))} toggleSort('category')} - className="text-left px-3 py-2 font-medium text-gray-700 cursor-pointer select-none hover:bg-gray-100" + className="text-left px-3 py-2 font-medium text-gray-700 dark:text-gray-300 cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-gray-700" > Category {sortCol === 'category' ? ( - {sortDir === 'asc' ? '\u25B2' : '\u25BC'} + {sortDir === 'asc' ? '▲' : '▼'} ) : ( - {'\u25B4'} + {'▴'} )} @@ -478,8 +478,8 @@ export default function AccessPackagesPage({ onOpenDetail }) { {sortedPackages.map(ap => ( toggleSelect(ap.id)} > @@ -525,8 +525,8 @@ export default function AccessPackagesPage({ onOpenDetail }) { {ap.complianceStatus === 'Missed' && ap.daysOverdue > 0 && ` (${ap.daysOverdue}d ago)`} {ap.reviewerInfo && (ap.complianceStatus === 'Missed' || ap.complianceStatus === 'In Progress') && ( -
- Reviewer: {ap.reviewerInfo} +
+ Reviewer: {ap.reviewerInfo}
)} {ap.missedReviewsCount > 0 && ( @@ -540,7 +540,7 @@ export default function AccessPackagesPage({ onOpenDetail }) {
) : ap.totalAssignments === 0 ? ( {ap.reviewerInfo && ( -
- Reviewer: {ap.reviewerInfo} +
+ Reviewer: {ap.reviewerInfo}
)} {ap.missedReviewsCount > 0 && ( @@ -571,17 +571,17 @@ export default function AccessPackagesPage({ onOpenDetail }) {
) : ( Not required )} - - {ap.lastReviewDate ? formatDate(ap.lastReviewDate) : -} + + {ap.lastReviewDate ? formatDate(ap.lastReviewDate) : -} - + {ap.lastReviewedBy ? ( /^AAD Access Review/i.test(ap.lastReviewedBy) ? ( - + - )} e.stopPropagation()}> @@ -603,7 +603,7 @@ export default function AccessPackagesPage({ onOpenDetail }) { value={ap.category?.id || ''} onChange={e => assignCategoryToOne(ap.id, e.target.value ? parseInt(e.target.value) : null)} disabled={busy} - className="px-1.5 py-0.5 border border-gray-200 rounded text-xs bg-white" + className="px-1.5 py-0.5 border border-gray-200 dark:border-gray-600 rounded text-xs bg-white dark:bg-gray-700 dark:text-gray-200" style={ap.category ? { backgroundColor: ap.category.color + '20', borderColor: ap.category.color, @@ -625,7 +625,7 @@ export default function AccessPackagesPage({ onOpenDetail }) { {/* Pagination */} {totalPages > 1 && ( -
+
Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total.toLocaleString()} @@ -633,7 +633,7 @@ export default function AccessPackagesPage({ onOpenDetail }) { @@ -641,7 +641,7 @@ export default function AccessPackagesPage({ onOpenDetail }) { diff --git a/app/ui/src/components/AdminPage.jsx b/app/ui/src/components/AdminPage.jsx index 21bcb6eb4..63b9b6506 100644 --- a/app/ui/src/components/AdminPage.jsx +++ b/app/ui/src/components/AdminPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, lazy, Suspense } from 'react'; +import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'; import { useAuth } from '../auth/AuthGate'; import ScheduleEditor from './ScheduleEditor'; @@ -7,6 +7,7 @@ const CrawlersPage = lazy(() => import('./CrawlersPage')); const ContainerStatsPage = lazy(() => import('./ContainerStatsPage')); const AuthSettingsPage = lazy(() => import('./AuthSettingsPage')); const PerfPage = lazy(() => import('./PerfPage')); +const AboutPage = lazy(() => import('./AboutPage')); const RiskProfileWizard = lazy(() => import('./RiskProfileWizard')); const CorrelationWizard = lazy(() => import('./CorrelationWizard')); @@ -24,8 +25,8 @@ function fmt(dateStr) { function MetaBadge({ label, value }) { if (!value) return null; return ( - - {label}: + + {label}: {value} ); @@ -37,7 +38,7 @@ function JsonViewer({ data }) {
- {open &&
{children}
} + {open &&
{children}
}
); } function NotConfigured({ message }) { return ( -
+
@@ -107,7 +108,7 @@ function RiskProfileSection() { }, [authFetch]); const content = () => { - if (loading) return

Loading...

; + if (loading) return

Loading...

; if (!data?.available) { return (
@@ -127,7 +128,7 @@ function RiskProfileSection() {
{!data.isActive && ( - + Not active — showing most recent )} @@ -142,20 +143,20 @@ function RiskProfileSection() { {cp.description && (
-

Organization Description

-

{cp.description}

+

Organization Description

+

{cp.description}

)} {regulations.length > 0 && (
-

Applicable Regulations ({regulations.length})

+

Applicable Regulations ({regulations.length})

{regulations.map((r, i) => ( {r.name || r.id || String(r)} @@ -166,14 +167,14 @@ function RiskProfileSection() { {criticalRoles.length > 0 && (
-

Critical Roles ({criticalRoles.length})

+

Critical Roles ({criticalRoles.length})

{criticalRoles.map((r, i) => { const titles = Array.isArray(r.title_patterns) ? r.title_patterns.join(', ') : (r.title || String(r)); return ( -
- {titles} - {r.rationale && — {r.rationale}} +
+ {titles} + {r.rationale && — {r.rationale}}
); })} @@ -183,16 +184,16 @@ function RiskProfileSection() { {knownSystems.length > 0 && (
-

Known Systems ({knownSystems.length})

+

Known Systems ({knownSystems.length})

{knownSystems.map((s, i) => ( {s.name || String(s)} @@ -204,8 +205,8 @@ function RiskProfileSection() { {criticalProcesses.length > 0 && (
-

Critical Business Processes ({criticalProcesses.length})

-
    +

    Critical Business Processes ({criticalProcesses.length})

    +
      {criticalProcesses.map((p, i) =>
    • {typeof p === 'string' ? p : (p.name || JSON.stringify(p))}
    • )}
@@ -213,16 +214,16 @@ function RiskProfileSection() { {riskDomains.length > 0 && (
-

Risk Domains ({riskDomains.length})

+

Risk Domains ({riskDomains.length})

{riskDomains.map((d, i) => ( {d.domain || d.name || String(d)} - {d.weight != null && {d.weight}} + {d.weight != null && {d.weight}} ))}
@@ -244,19 +245,19 @@ function RiskProfileSection() { // each classifier: { id, label, description, patterns:[], score, tier, domain } const TIER_STYLES_SMALL = { - critical: 'bg-red-100 text-red-700', - high: 'bg-orange-100 text-orange-700', - medium: 'bg-yellow-100 text-yellow-700', - low: 'bg-blue-100 text-blue-700', + critical: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300', + high: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300', + medium: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300', + low: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300', }; function ClassifierTable({ rules, emptyMsg }) { - if (!rules?.length) return

{emptyMsg}

; + if (!rules?.length) return

{emptyMsg}

; return ( -
+
- + @@ -264,19 +265,19 @@ function ClassifierTable({ rules, emptyMsg }) { - + {rules.map((rule, i) => { const patterns = Array.isArray(rule.patterns) ? rule.patterns : (rule.patterns ? [rule.patterns] : []); const tier = (rule.tier || '').toLowerCase(); return ( - - + - - + ); })} @@ -350,7 +351,7 @@ function ClassifiersSection() { }; const content = () => { - if (loading) return

Loading...

; + if (loading) return

Loading...

; if (!data?.available) { return (
@@ -368,7 +369,7 @@ function ClassifiersSection() {
{!data.isActive && ( - + Not active — showing most recent )} @@ -382,7 +383,7 @@ function ClassifiersSection() {
{/* Sub-tabs */} -
+
{[ ['groups', `Groups (${groupRules.length})`], ['users', `Users (${userRules.length})`], @@ -394,7 +395,7 @@ function ClassifiersSection() { className={`pb-2 px-1 text-sm font-medium border-b-2 transition-colors ${ activeTab === key ? 'border-blue-500 text-blue-600' - : 'border-transparent text-gray-500 hover:text-gray-700' + : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200' }`} > {label} @@ -408,14 +409,14 @@ function ClassifiersSection() { {/* Schedules section (only show for active classifier) */} {data.isActive && ( -
-

Automatic Scoring Schedules

-

+

+

Automatic Scoring Schedules

+

Configure when risk scoring runs automatically. Schedules re-run the active classifiers over the latest data.

{schedules.length === 0 && ( -
+
No schedules configured. Scoring will only run when triggered manually.
)} @@ -430,14 +431,14 @@ function ClassifiersSection() {
- {scheduleError && {scheduleError}} + {scheduleError && {scheduleError}}
)} @@ -463,10 +464,10 @@ function NewCorrelationRulesetLauncher({ onRefresh }) { }; return ( -
+
-
Create a new account correlation ruleset
-
+
Create a new account correlation ruleset
+
Generates correlation signals and account type rules to link accounts across systems.
@@ -506,7 +507,7 @@ function CorrelationSection() { }, [authFetch]); // eslint-disable-line react-hooks/exhaustive-deps const content = () => { - if (loading) return

Loading...

; + if (loading) return

Loading...

; if (!data?.available) { return (
@@ -534,7 +535,7 @@ function CorrelationSection() {
{/* Sub-tabs */} -
+
{[ ['signals', `Correlation Signals (${signals.length})`], ['accountTypes', `Account Types (${accountTypeRules.length})`], @@ -546,7 +547,7 @@ function CorrelationSection() { className={`pb-2 px-1 text-sm font-medium border-b-2 transition-colors ${ activeTab === key ? 'border-blue-500 text-blue-600' - : 'border-transparent text-gray-500 hover:text-gray-700' + : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200' }`} > {label} @@ -556,30 +557,30 @@ function CorrelationSection() { {activeTab === 'signals' && ( signals.length === 0 - ?

No correlation signals defined.

- :
+ ?

No correlation signals defined.

+ :
Label Patterns ScoreDomain
+
{rule.label || rule.id || '—'} {rule.description && ( -

{rule.description}

+

{rule.description}

)}
+ {patterns.length === 0 ? '—' : (
{patterns.map((p, pi) =>
{p}
)} @@ -285,19 +286,19 @@ function ClassifierTable({ rules, emptyMsg }) {
= 70 ? 'bg-red-100 text-red-700' : - (rule.score || 0) >= 40 ? 'bg-orange-100 text-orange-700' : - 'bg-gray-100 text-gray-600' + (rule.score || 0) >= 70 ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300' : + (rule.score || 0) >= 40 ? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300' : + 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400' }`}>{rule.score ?? '—'} {tier ? ( - + {tier} ) : '—'} {rule.domain || '—'}{rule.domain || '—'}
- + - + {signals.map((s, i) => ( - - - + + + - + ))} @@ -589,54 +590,54 @@ function CorrelationSection() { {activeTab === 'accountTypes' && ( accountTypeRules.length === 0 - ?

No account type rules defined.

+ ?

No account type rules defined.

:
{accountTypeRules.map((rule, i) => ( -
+
- {rule.accountType || rule.type || `Rule ${i + 1}`} + {rule.accountType || rule.type || `Rule ${i + 1}`} {rule.priority !== undefined && ( - priority {rule.priority} + priority {rule.priority} )}
{rule.patterns?.length > 0 && (
{rule.patterns.map((p, j) => ( - {p} + {p} ))}
)} - {rule.description &&

{rule.description}

} + {rule.description &&

{rule.description}

}
))}
)} {activeTab === 'hr' && hrConfig && ( -
+
{hrConfig.sourceSystem && }
{hrConfig.indicators?.length > 0 && (
-

Indicators

-
+

Indicators

+
Signal Type Weight Description
{s.name || s.signal || '—'}{s.type || s.matchType || '—'}
{s.name || s.signal || '—'}{s.type || s.matchType || '—'} = 70 ? 'bg-green-100 text-green-700' : - (s.weight || 0) >= 40 ? 'bg-blue-100 text-blue-700' : - 'bg-gray-100 text-gray-600' + (s.weight || 0) >= 70 ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300' : + (s.weight || 0) >= 40 ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : + 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400' }`}>{s.weight ?? '—'} {s.description || '—'}{s.description || '—'}
- + - + {hrConfig.indicators.map((ind, i) => ( - - - + + + ))} @@ -656,6 +657,235 @@ function CorrelationSection() { return
{content()}
; } +// ── Power Query workbook + read-API tokens ──────────────────────── +// Lets a tenant admin mint read-only API tokens (`fgr_…`) and download a +// pre-stamped Excel workbook with those credentials baked in. Tokens are +// shown in plaintext exactly once at creation; everywhere else we display +// just the prefix. Revoking a token here flips the `revoked` flag in the DB +// — refresh attempts from any workbook holding that token start failing +// immediately. +function PowerQueryExportSection() { + const { authFetch } = useAuth(); + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [newToken, setNewToken] = useState(null); // plaintext shown once + const [showCreate, setShowCreate] = useState(false); + const [name, setName] = useState(''); + + const refresh = useCallback(async () => { + try { + const r = await authFetch('/api/admin/read-tokens'); + if (r.ok) setTokens(await r.json()); + } catch (e) { setError(e.message); } + setLoading(false); + }, [authFetch]); + + useEffect(() => { refresh(); }, [refresh]); + + async function downloadWorkbook() { + setError(null); + setBusy(true); + try { + const r = await authFetch('/api/admin/data-export/workbook', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + throw new Error(d.error || 'Workbook generation failed'); + } + const blob = await r.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `IdentityAtlas-${new Date().toISOString().slice(0, 10)}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + await refresh(); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + } + + async function createTokenOnly() { + if (!name.trim()) return; + setError(null); + setBusy(true); + try { + const r = await authFetch('/api/admin/read-tokens', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name.trim() }), + }); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + throw new Error(d.error || 'Token creation failed'); + } + const data = await r.json(); + setNewToken(data.token); // plaintext, shown once + setShowCreate(false); + setName(''); + await refresh(); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + } + + async function revoke(id) { + if (!confirm('Revoke this token? Workbooks using it will stop refreshing immediately.')) return; + setBusy(true); + try { + const r = await authFetch(`/api/admin/read-tokens/${id}`, { method: 'DELETE' }); + if (!r.ok) throw new Error('Revoke failed'); + await refresh(); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + } + + function fmtDate(s) { return s ? new Date(s).toLocaleString() : '—'; } + + return ( +
+
+

+ Download a pre-configured Excel workbook with Power Query M code for every + object type (Users, Resources, Assignments, etc). The workbook includes a + read-only API token so refreshing the data on any user's machine just + requires opening the file. +

+ +
+ + +
+ + {showCreate && ( +
+ setName(e.target.value)} + onKeyDown={e => e.key === 'Enter' && createTokenOnly()} + placeholder="Token name (e.g. 'PowerBI prod report')" + className="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-sm flex-1 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500" + /> + + +
+ )} + + {newToken && ( +
+

⚠ Copy this token now — it will not be shown again

+ {newToken} + + +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+

Existing tokens

+ {loading ? ( +

Loading…

+ ) : tokens.length === 0 ? ( +

No tokens issued yet.

+ ) : ( +
Attribute Value Weight
{ind.attribute}{ind.value}
{ind.attribute}{ind.value} - {ind.weight ?? '—'} + {ind.weight ?? '—'}
+ + + + + + + + + + + + {tokens.map(t => ( + + + + + + + + + ))} + +
NamePrefixCreatedLast usedStatus
{t.name}{t.tokenPrefix}…{fmtDate(t.createdAt)}{fmtDate(t.lastUsedAt)} + {t.revoked + ? Revoked + : t.expiresAt && new Date(t.expiresAt) < new Date() + ? Expired + : Active} + + {!t.revoked && ( + + )} +
+ )} +
+
+ + ); +} + // ── Curated Data section ────────────────────────────────────────── function CuratedDataSection() { @@ -742,10 +972,10 @@ function CuratedDataSection() { return (
-

+

Export and import manually curated data — user tags, group/resource tags, and business role categories — so they can be restored after recreating an environment. - Analyst overrides are managed separately via Export-FGCuratedData. + Analyst overrides are managed separately via Export-FGCuratedData.

{/* Buttons */} @@ -753,14 +983,14 @@ function CuratedDataSection() { {totalRows != null && ( - {totalRows.toLocaleString()} history rows stored + {totalRows.toLocaleString()} history rows stored )}
{message && ( -
+
{message.text}
)} @@ -1009,32 +1239,32 @@ function DangerZoneSection({ onRefresh }) { }; return ( -
-
+
+
⚠️ - Danger Zone + Danger Zone
-

Clean Database

-

+

Clean Database

+

Wipes all identity data (users, groups, assignments, identities, governance, sync log) but preserves crawler configurations, risk profiles, and correlation rules. Use this when you want to re-sync from a clean slate without re-creating your crawler setup.

{result && ( -
-
Database cleaned
-
+
+
Database cleaned
+
Wiped {result.wiped?.length || 0} table{result.wiped?.length !== 1 ? 's' : ''} {result.skipped?.length > 0 && ` (${result.skipped.length} skipped)`}
{result.wiped?.length > 0 && (
- Show details -
    + Show details +
      {result.wiped.map(w => (
    • {w.table}: {w.rowsAffected} rows{w.temporal ? ' (temporal)' : ''} @@ -1043,14 +1273,14 @@ function DangerZoneSection({ onRefresh }) {
)} - +
)} {error && ( -
-
{error}
- +
+
{error}
+
)} @@ -1064,9 +1294,9 @@ function DangerZoneSection({ onRefresh }) { )} {confirmStep === 1 && ( -
-

Are you sure?

-

+

+

Are you sure?

+

This will delete all identity data. Crawler configurations and risk profiles will be kept. You'll need to re-run your crawlers to populate the data again.

@@ -1079,7 +1309,7 @@ function DangerZoneSection({ onRefresh }) { @@ -1088,17 +1318,17 @@ function DangerZoneSection({ onRefresh }) { )} {confirmStep === 2 && ( -
-

Final confirmation

-

- Type DELETE ALL DATA to confirm: +

+

Final confirmation

+

+ Type DELETE ALL DATA to confirm:

setTypedConfirm(e.target.value)} placeholder="DELETE ALL DATA" - className="w-full p-2 border rounded mb-3 text-sm font-mono" + className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded mb-3 text-sm font-mono dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500" />
@@ -1132,6 +1362,7 @@ const ADMIN_TABS = [ { key: 'performance', label: 'Performance', description: 'API and SQL performance metrics' }, { key: 'containers', label: 'Containers', description: 'Live CPU, memory and network for the Docker stack' }, { key: 'auth', label: 'Authentication', description: 'Configure Entra ID single sign-on' }, + { key: 'about', label: 'About', description: 'License, version, and software bill of materials' }, ]; // ─── LLM Settings sub-tab ──────────────────────────────────────────────────── @@ -1286,13 +1517,13 @@ function LLMSettingsSection() { // list for Anthropic is not valid for OpenAI. useEffect(() => { setModels(null); setModelsError(null); }, [config.provider]); - if (loading) return
Loading…
; + if (loading) return
Loading…
; return (
-
-

LLM Provider

-

+

+

LLM Provider

+

Used by risk profiling, classifier generation and conversational refinement. The API key is encrypted at rest with envelope encryption — only the masked status is visible after saving.

@@ -1300,11 +1531,11 @@ function LLMSettingsSection() {
{/* Provider */}
- + setConfig(c => ({ ...c, model: e.target.value }))} - className="w-full px-3 py-1.5 text-sm border rounded font-mono" + className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded font-mono dark:bg-gray-700 dark:text-gray-200" > {models.map(m => ( @@ -1345,14 +1576,14 @@ function LLMSettingsSection() { value={config.model} onChange={e => setConfig(c => ({ ...c, model: e.target.value }))} placeholder={placeholderModel || (isAzure ? 'e.g. gpt-4o-prod' : '')} - className="w-full px-3 py-1.5 text-sm border rounded font-mono" + className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500" /> )} {modelsError && ( -
Model discovery failed: {modelsError}
+
Model discovery failed: {modelsError}
)} {models && models.length === 0 && ( -
No models returned — check your API key permissions.
+
No models returned — check your API key permissions.
)}
@@ -1360,33 +1591,33 @@ function LLMSettingsSection() { {isAzure && ( <>
- + setConfig(c => ({ ...c, endpoint: e.target.value }))} placeholder="https://my-resource.openai.azure.com" - className="w-full px-3 py-1.5 text-sm border rounded font-mono" + className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500" />
- + setConfig(c => ({ ...c, deployment: e.target.value }))} placeholder="gpt-4o-prod" - className="w-full px-3 py-1.5 text-sm border rounded font-mono" + className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500" />
- + setConfig(c => ({ ...c, apiVersion: e.target.value }))} placeholder="2024-08-01-preview" - className="w-full px-3 py-1.5 text-sm border rounded font-mono" + className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500" />
@@ -1394,8 +1625,8 @@ function LLMSettingsSection() { {/* API key */}
-
@@ -1412,21 +1643,21 @@ function LLMSettingsSection() { {apiKeySet && ( @@ -1434,12 +1665,12 @@ function LLMSettingsSection() {
{message && ( -
+
{message.text}
)} {testResult && ( -
+
{testResult.ok ? ( <>
Connection OK
@@ -1464,10 +1695,10 @@ function NewRiskProfileLauncher({ onRiskScoresRefresh }) { const [open, setOpen] = useState(false); const [bumpKey, setBumpKey] = useState(0); return ( -
+
-
Create a new risk profile
-
+
Create a new risk profile
+
Walks you through generating an organisational profile and classifier set with the LLM, then optionally runs a scoring pass.
@@ -1531,18 +1762,18 @@ function RiskScoringSection({ onRiskScoresRefresh }) { return (
{/* Feature toggle card */} -
+
-

Risk Scoring Feature

-

+

Risk Scoring Feature

+

Risk scoring assigns a 0-100 risk score to every identity based on direct classifier matches, membership analysis, structural hygiene checks, and cross-entity propagation. When disabled, the Risk Scores tab is hidden from the main navigation and the scoring engine is skipped during sync runs.

{error && ( -
{error}
+
{error}
)}
@@ -1550,7 +1781,7 @@ function RiskScoringSection({ onRiskScoresRefresh }) { onClick={handleToggle} disabled={toggling || features === null} className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${ - enabled ? 'bg-emerald-600' : 'bg-gray-300' + enabled ? 'bg-emerald-600' : 'bg-gray-300 dark:bg-gray-600' } disabled:opacity-50`} title={enabled ? 'Disable risk scoring' : 'Enable risk scoring'} > @@ -1560,7 +1791,7 @@ function RiskScoringSection({ onRiskScoresRefresh }) { }`} /> -
+
{toggling ? '...' : enabled ? 'Enabled' : 'Disabled'}
@@ -1575,7 +1806,7 @@ function RiskScoringSection({ onRiskScoresRefresh }) { ) : ( -
+
Risk Scoring is disabled. Enable the feature toggle above to configure profiles and classifiers.
)} @@ -1585,7 +1816,7 @@ function RiskScoringSection({ onRiskScoresRefresh }) { function AdminSubTabs({ activeTab, onTabChange }) { return ( -
+
}> + Loading…
}> )} {activeTab === 'containers' && ( - Loading…
}> + Loading…
}> )} {activeTab === 'auth' && ( - Loading…
}> + Loading…
}> )} + + {activeTab === 'about' && ( + Loading…
}> + + + )}
); diff --git a/app/ui/src/components/AuthSettingsPage.jsx b/app/ui/src/components/AuthSettingsPage.jsx index 4225de93f..4707d83fe 100644 --- a/app/ui/src/components/AuthSettingsPage.jsx +++ b/app/ui/src/components/AuthSettingsPage.jsx @@ -1,23 +1,6 @@ import { useEffect, useState } from 'react'; import { useAuth } from '../auth/AuthGate'; -// Admin → Authentication sub-tab. -// -// READ-ONLY by design. There is no Save button, no input form, no PUT endpoint. -// Auth configuration is changed via the CLI tool inside the web container, which -// avoids exposing an unauthenticated mutation surface that controls authentication. -// -// What this page does: -// 1. Shows the current state (enabled / disabled, tenant id, client id, roles) -// 2. Walks the operator through the Entra ID app-registration steps -// 3. Provides copy-pasteable `docker compose exec` commands to enable/disable -// 4. Auto-detects window.location.origin so the operator knows the exact -// redirect URI to register in their Entra app -// -// Multi-domain support: register every URL (localhost:3001 + production domain) -// as a separate redirect URI in the same Entra app. The frontend uses -// window.location.origin at runtime so each environment "just works". - function CopyableCommand({ command }) { const [copied, setCopied] = useState(false); const handleCopy = () => { @@ -44,8 +27,6 @@ export default function AuthSettingsPage() { const [error, setError] = useState(null); const [state, setState] = useState(null); - // The current page origin — what the user must register as a redirect URI in - // Entra ID. Computed in the browser so it Just Works™ on every domain. const currentOrigin = typeof window !== 'undefined' ? window.location.origin : ''; const refresh = async () => { @@ -65,7 +46,7 @@ export default function AuthSettingsPage() { useEffect(() => { refresh(); }, [authFetch]); // eslint-disable-line react-hooks/exhaustive-deps if (loading && !state) { - return
Loading authentication settings...
; + return
Loading authentication settings...
; } const enabled = state?.enabled === true; @@ -73,9 +54,6 @@ export default function AuthSettingsPage() { const clientId = state?.clientId || ''; const requiredRoles = state?.requiredRoles || []; - // Build the example enable command. If the operator has already configured - // tenant + client (via CLI or env var) we pre-fill them so the displayed - // command is a copy-paste-able restore. Otherwise we use placeholders. const exampleTenant = tenantId || ''; const exampleClient = clientId || ''; const enableCmd = `docker compose exec web node /app/backend/src/cli/auth-config.js \\ @@ -92,83 +70,87 @@ export default function AuthSettingsPage() { return (
{/* ─── Current state card ─────────────────────────────── */} -
+
-

Authentication

-

+

Authentication

+

{enabled ? 'Entra ID SSO is enabled. Users must sign in with their Microsoft account to access the application.' : 'Authentication is disabled. Anyone with the URL can access this application.'}

- + {enabled ? 'ENABLED' : 'DISABLED'}
-
+
-
Tenant ID
-
{tenantId || — not set —}
+
Tenant ID
+
{tenantId || — not set —}
-
Client ID
-
{clientId || — not set —}
+
Client ID
+
{clientId || — not set —}
-
Required roles
-
{requiredRoles.length ? requiredRoles.join(', ') : — any signed-in user —}
+
Required roles
+
{requiredRoles.length ? requiredRoles.join(', ') : — any signed-in user —}
- {error &&
{error}
} - + {error &&
{error}
} +
{/* ─── How to change it ──────────────────────────────── */} -
-

Changing authentication settings

-

+

+

Changing authentication settings

+

Auth config is intentionally not editable from this page. Allowing it would require leaving an unauthenticated mutation endpoint open whenever auth was off — exactly the kind of hole that defeats the point of having auth in the first place. Configuration is done via a CLI tool inside the web container, which only the host running Docker can reach.

-

Check current settings

+

Check current settings

-

Enable authentication

-

Run this on the host where Docker is running:

+

Enable authentication

+

Run this on the host where Docker is running:

-

Or with required app roles (only users with one of these roles can sign in):

+

Or with required app roles (only users with one of these roles can sign in):

-

Disable authentication (recovery)

+

Disable authentication (recovery)

-

Apply changes

-

After any change, restart the web container so the API picks up the new state:

+

Apply changes

+

After any change, restart the web container so the API picks up the new state:

{/* ─── Setup walkthrough ──────────────────────────────── */} -
-

Entra ID app registration walkthrough

-

+

+

Entra ID app registration walkthrough

+

Before running the enable command, register Identity Atlas as an application in your Entra ID tenant. You only need to do this once per tenant.

  1. - 1 + 1
    -
    Create an App Registration
    -
    - Go to Entra ID → App registrations → New registration. - Name it Identity Atlas (or whatever you prefer). +
    Create an App Registration
    +
    + Go to Entra ID → App registrations → New registration. + Name it Identity Atlas (or whatever you prefer). Account types: Accounts in this organizational directory only. Leave the redirect URI empty for now.
    @@ -176,21 +158,21 @@ export default function AuthSettingsPage() {
  2. - 2 + 2
    -
    Add a Single-Page Application redirect URI
    -
    +
    Add a Single-Page Application redirect URI
    +
    In the new app, go to Authentication → Add a platform → Single-page application. Add this URI:
    - {currentOrigin} + {currentOrigin}
    -
    +
    If you also access Identity Atlas from another URL (production domain, reverse proxy, etc.), add each one as a separate redirect URI in the same Entra app.
    @@ -198,35 +180,35 @@ export default function AuthSettingsPage() {
  3. - 3 + 3
    -
    Expose an API scope
    -
    +
    Expose an API scope
    +
    Go to Expose an API → Add a scope. Accept the default Application ID URI - (api://<client-id>), - then create a scope named access. + (api://<client-id>), + then create a scope named access.
  4. - 4 + 4
    -
    (Optional) Define App roles
    -
    +
    (Optional) Define App roles
    +
    If you want to restrict access to specific groups of users, define App roles under App roles → Create app role - {' '}(e.g. IdentityAtlas.Read, IdentityAtlas.Admin), + {' '}(e.g. IdentityAtlas.Read, IdentityAtlas.Admin), then assign them to users via Enterprise applications → <your app> → Users and groups. - Pass the role names to the CLI's --roles flag. + Pass the role names to the CLI's --roles flag.
  5. - 5 + 5
    -
    Run the enable CLI command
    -
    +
    Run the enable CLI command
    +
    Grab the Directory (tenant) ID and Application (client) ID from the app's Overview page, then run the enable command above. Restart web. You'll be redirected to Entra at the next page load.
    diff --git a/app/ui/src/components/ConfidenceBar.jsx b/app/ui/src/components/ConfidenceBar.jsx index 207047157..b03eeee8e 100644 --- a/app/ui/src/components/ConfidenceBar.jsx +++ b/app/ui/src/components/ConfidenceBar.jsx @@ -4,18 +4,18 @@ export default function ConfidenceBar({ confidence }) { if (confidence == null) { return (
    -
    - —% +
    + —%
    ); } const color = confidence >= 90 ? 'bg-green-500' : confidence >= 70 ? 'bg-blue-500' : confidence >= 50 ? 'bg-yellow-500' : 'bg-orange-500'; return (
    -
    +
    - {confidence}% + {confidence}%
    ); } diff --git a/app/ui/src/components/ContainerStatsPage.jsx b/app/ui/src/components/ContainerStatsPage.jsx index 7583b3cc1..d656eca37 100644 --- a/app/ui/src/components/ContainerStatsPage.jsx +++ b/app/ui/src/components/ContainerStatsPage.jsx @@ -12,7 +12,7 @@ function fmtBytes(n) { function Bar({ percent, color }) { const p = Math.max(0, Math.min(100, percent || 0)); return ( -
    +
    ); @@ -21,7 +21,7 @@ function Bar({ percent, color }) { function LineChart({ data, maxValue, color, height = 60, label }) { if (!data || data.length === 0) { return ( -
    +
    Collecting data...
    ); @@ -55,8 +55,8 @@ function LineChart({ data, maxValue, color, height = 60, label }) { return (
    -
    {label}
    - +
    {label}
    + {gridLines.map((line, i) => ( { const next = { ...prev }; for (const c of j.containers) { @@ -166,10 +164,9 @@ export default function ContainerStatsPage() { h.cpu.push({ timestamp: now, value: c.cpuPercent || 0 }); h.memory.push({ timestamp: now, value: c.memPercent || 0 }); - h.netRx.push({ timestamp: now, value: (rate.rxRate || 0) / 1024 / 1024 }); // MB/s - h.netTx.push({ timestamp: now, value: (rate.txRate || 0) / 1024 / 1024 }); // MB/s + h.netRx.push({ timestamp: now, value: (rate.rxRate || 0) / 1024 / 1024 }); + h.netTx.push({ timestamp: now, value: (rate.txRate || 0) / 1024 / 1024 }); - // Keep only last MAX_HISTORY_POINTS if (h.cpu.length > MAX_HISTORY_POINTS) h.cpu.shift(); if (h.memory.length > MAX_HISTORY_POINTS) h.memory.shift(); if (h.netRx.length > MAX_HISTORY_POINTS) h.netRx.shift(); @@ -190,26 +187,26 @@ export default function ContainerStatsPage() { if (error) { return ( -
    +
    Could not load container stats
    {error}
    -
    +
    The web container needs read-only access to the Docker socket. After updating docker-compose.yml, run: - docker compose up -d web + docker compose up -d web
    ); } - if (!data) return
    Loading container stats…
    ; + if (!data) return
    Loading container stats…
    ; if (data.unavailable) { return ( -
    +
    Container stats unavailable
    The web container cannot access the Docker socket to read container metrics.
    - {data.reason &&
    {data.reason}
    } -
    + {data.reason &&
    {data.reason}
    } +

    To enable container monitoring, the Docker socket must be readable by the web container. Common fixes:

    • Linux: sudo chmod 666 /var/run/docker.sock (or add the container user to the docker group)
    • @@ -223,7 +220,7 @@ export default function ContainerStatsPage() { return (
      -
      +
      Auto-refreshing every 3s · Last update: {new Date(data.timestamp).toLocaleTimeString()}
      {data.containers.map(c => { @@ -231,78 +228,60 @@ export default function ContainerStatsPage() { const rate = rates[c.name] || {}; const h = history[c.name] || { cpu: [], memory: [], netRx: [], netTx: [] }; return ( -
      +
      -

      +

      {meta.icon}{meta.label}

      -

      {c.name} · {c.status}

      +

      {c.name} · {c.status}

      {c.state}
      {c.error ? ( -
      Stats error: {c.error}
      +
      Stats error: {c.error}
      ) : ( <>
      -
      +
      CPU - {c.cpuPercent.toFixed(1)}% + {c.cpuPercent.toFixed(1)}%
      80 ? 'bg-red-500' : c.cpuPercent > 50 ? 'bg-amber-500' : 'bg-emerald-500'} />
      -
      +
      Memory - {fmtBytes(c.memUsageBytes)} / {fmtBytes(c.memLimitBytes)} ({c.memPercent.toFixed(0)}%) + {fmtBytes(c.memUsageBytes)} / {fmtBytes(c.memLimitBytes)} ({c.memPercent.toFixed(0)}%)
      80 ? 'bg-red-500' : c.memPercent > 50 ? 'bg-amber-500' : 'bg-blue-500'} />
      -
      Network
      -
      - ↓ {rate.rxRate != null ? `${fmtBytes(rate.rxRate)}/s` : '—'} ({fmtBytes(c.netRxBytes)} total) +
      Network
      +
      + ↓ {rate.rxRate != null ? `${fmtBytes(rate.rxRate)}/s` : '—'} ({fmtBytes(c.netRxBytes)} total)
      -
      - ↑ {rate.txRate != null ? `${fmtBytes(rate.txRate)}/s` : '—'} ({fmtBytes(c.netTxBytes)} total) +
      + ↑ {rate.txRate != null ? `${fmtBytes(rate.txRate)}/s` : '—'} ({fmtBytes(c.netTxBytes)} total)
      -
      +
      - - - d.value))} - color="#8b5cf6" - label="Network RX (MB/s)" - /> - d.value))} - color="#f59e0b" - label="Network TX (MB/s)" - /> + + + d.value))} color="#8b5cf6" label="Network RX (MB/s)" /> + d.value))} color="#f59e0b" label="Network TX (MB/s)" />
      -
      +
      Processes: {c.pids}
      diff --git a/app/ui/src/components/ContextDetailPage.jsx b/app/ui/src/components/ContextDetailPage.jsx index 5d0c58f7d..b4728b5e4 100644 --- a/app/ui/src/components/ContextDetailPage.jsx +++ b/app/ui/src/components/ContextDetailPage.jsx @@ -1,12 +1,24 @@ import { useState, useEffect, useCallback } from 'react'; import { useAuth } from '../auth/AuthGate'; import RiskScoreSection from './RiskScoreSection'; +import ManualContextEditor from './contexts/ManualContextEditor'; +import ContextMemberPicker from './contexts/ContextMemberPicker'; +import { variantMeta, targetTypeMeta } from '../utils/contextStyles'; // ─── Context Detail Page ────────────────────────────────────────────────────── -// Shows details for a single Context: attributes, members (via Identities), sub-contexts. -// Loaded via /api/contexts/:id +// Shows details for a single Context (v6 shape): header with variant / +// target / scope-system / owner, paginated members, sub-contexts. +// Loaded via /api/contexts/:id. -const SYSTEM_COLS = new Set(['SysStartTime', 'SysEndTime', 'ValidFrom', 'ValidTo']); +const SYSTEM_COLS = new Set([ + 'SysStartTime', 'SysEndTime', 'ValidFrom', 'ValidTo', + // New-shape fields already surfaced in the header — don't repeat in the + // attributes grid. + 'id', 'variant', 'targetType', 'contextType', 'displayName', 'description', + 'parentContextId', 'scopeSystemId', 'scopeSystemName', 'sourceAlgorithmId', + 'sourceAlgorithmName', 'sourceAlgorithmDisplayName', 'sourceRunId', + 'createdByUser', 'ownerUserId', 'externalId', 'parentDisplayName', +]); function cleanAttributes(attrs) { if (!attrs) return {}; @@ -28,6 +40,7 @@ export default function ContextDetailPage({ contextId, cachedData, onCacheData, // Paginated members const [memberPage, setMemberPage] = useState(0); const [memberSearch, setMemberSearch] = useState(''); + const [includeDescendants, setIncludeDescendants] = useState(false); const [members, setMembers] = useState([]); const [memberTotal, setMemberTotal] = useState(0); const [membersLoading, setMembersLoading] = useState(false); @@ -73,6 +86,7 @@ export default function ContextDetailPage({ contextId, cachedData, onCacheData, offset: String(memberPage * PAGE_SIZE), }); if (memberSearch) params.set('search', memberSearch); + if (includeDescendants) params.set('include', 'descendants'); const res = await authFetch(`/api/contexts/${contextId}/members?${params}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); @@ -83,31 +97,31 @@ export default function ContextDetailPage({ contextId, cachedData, onCacheData, } finally { setMembersLoading(false); } - }, [authFetch, contextId, memberPage, memberSearch]); + }, [authFetch, contextId, memberPage, memberSearch, includeDescendants]); useEffect(() => { fetchMembers(); }, [fetchMembers]); - // Reset page when search changes - useEffect(() => { setMemberPage(0); }, [memberSearch]); + // Reset page when search / scope changes + useEffect(() => { setMemberPage(0); }, [memberSearch, includeDescendants]); // ─── Render ──────────────────────────────────────────────────────── if (loading) { return (
      -
      Loading context details...
      +
      Loading context details...
      ); } if (error) { return ( -
      -

      Failed to load context

      -

      {error}

      +
      +

      Failed to load context

      +

      {error}

      - - + +
      ); @@ -115,9 +129,9 @@ export default function ContextDetailPage({ contextId, cachedData, onCacheData, if (!detail || !detail.attributes) { return ( -
      +
      Context not found. - +
      ); } @@ -125,62 +139,62 @@ export default function ContextDetailPage({ contextId, cachedData, onCacheData, const attrs = cleanAttributes(detail.attributes); const subContexts = detail.subContexts || []; const totalPages = Math.ceil(memberTotal / PAGE_SIZE); + const isManual = detail.attributes.variant === 'manual'; + const isGenerated = detail.attributes.variant === 'generated'; + // Analyst-owned membership writes work for both manual + generated + // contexts. Synced is the only variant we refuse — the source system + // would overwrite the analyst edit on the next crawl. + const canEditMembers = isManual || isGenerated; + + async function removeMember(memberId) { + try { + const r = await authFetch(`/api/contexts/${contextId}/members/${memberId}`, { method: 'DELETE' }); + if (!r.ok && r.status !== 204) { + const body = await r.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${r.status}`); + } + fetchMembers(); fetchDetail(); + } catch (err) { + console.error('Remove member failed:', err); + } + } return (
      {/* Header */} -
      -
      -
      -
      -
      - CTX -
      -
      -

      {attrs.displayName || contextId}

      - {attrs.contextType && ( - - {attrs.contextType} - - )} -
      -
      - {attrs.description && ( -

      {attrs.description}

      - )} -
      - -
      -
      + + {detail.attributes.description && ( +
      {detail.attributes.description}
      + )} + + {/* Manual-context inline editor */} + {isManual && ( + fetchDetail()} + onDeleted={() => onClose?.()} + /> + )} + + {/* Generated-context actions — delete only; everything else is owned + by the plugin that produced this row. */} + {isGenerated && ( + onClose?.()} + /> + )} {/* Risk Score */} {riskData && } - {/* Attributes */} -
      -

      Attributes

      -
      - {Object.entries(attrs).map(([key, value]) => ( -
      - {key} - {String(value)} -
      - ))} -
      -
      - {/* Sub-contexts */} {subContexts.length > 0 && ( -
      -

      +
      +

      Sub-contexts ({subContexts.length})

      @@ -188,14 +202,14 @@ export default function ContextDetailPage({ contextId, cachedData, onCacheData, ))} @@ -203,60 +217,122 @@ export default function ContextDetailPage({ contextId, cachedData, onCacheData,
      )} + {/* Attributes JSON (non-header fields) */} + {Object.keys(attrs).length > 0 && ( +
      +

      Attributes

      +
      + {Object.entries(attrs).map(([key, value]) => ( +
      + {key} + {typeof value === 'object' ? JSON.stringify(value) : String(value)} +
      + ))} +
      +
      + )} + {/* Members */} -
      -
      -

      +
      +
      +

      Members ({memberTotal}) + {detail.attributes.totalMemberCount > (detail.attributes.directMemberCount || 0) && !includeDescendants && ( + + direct only — {detail.attributes.directMemberCount || 0} of {detail.attributes.totalMemberCount} total + + )}

      - setMemberSearch(e.target.value)} - placeholder="Search members..." - className="text-sm border border-gray-200 rounded-lg px-3 py-1 w-64 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:border-transparent" - aria-label="Search members" - /> +
      + + setMemberSearch(e.target.value)} + placeholder="Search members..." + className="text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1 w-64 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-sky-400 dark:focus:ring-sky-500 focus:border-transparent" + aria-label="Search members" + /> +
      + {canEditMembers && ( +
      + {isGenerated && ( +

      + Manually-added members (addedBy=analyst) survive future plugin runs. + Algorithm-produced members are replaced on every run. +

      + )} + m.id)} + onAdded={() => { fetchMembers(); fetchDetail(); }} + /> +
      + )} + {membersLoading ? ( -
      Loading members...
      +
      Loading members...
      ) : members.length === 0 ? ( -
      No members found.
      +
      No members found.
      ) : ( <> - + + {canEditMembers && } {members.map(m => ( onOpenDetail('user', m.id, m.displayName)} > - - - + + + + {canEditMembers && ( + + )} ))} @@ -264,21 +340,21 @@ export default function ContextDetailPage({ contextId, cachedData, onCacheData, {/* Pagination */} {totalPages > 1 && ( -
      +
      - + Page {memberPage + 1} of {totalPages} @@ -290,3 +366,162 @@ export default function ContextDetailPage({ contextId, cachedData, onCacheData,
      ); } + +// ─── Header — surfaces provenance (variant, target, system, owner) ──────── +function ContextHeader({ attrs, onClose }) { + const v = variantMeta(attrs.variant); + const t = targetTypeMeta(attrs.targetType); + const provenance = describeProvenance(attrs); + + return ( +
      +
      +
      +
      +
      + {provenance &&

      {provenance}

      } + {attrs.parentDisplayName && ( +

      Parent: {attrs.parentDisplayName}

      + )} +
      + +
      +
      + ); +} + +// Per-row Remove button. On a manual context, every member was added by +// an analyst and remove is final. On a generated context, members have +// addedBy='algorithm' (plugin output) or 'analyst' (manual addition); +// the remove button differentiates so the analyst knows whether the row +// will come back on the next plugin run. +function RemoveMemberButton({ memberRow, onRemove, isGenerated }) { + const isAlgoRow = memberRow.addedBy === 'algorithm'; + if (isGenerated && isAlgoRow) { + return ( + + ); + } + return ( + + ); +} + +// ─── Generated-context actions (delete) ─────────────────────────────────── +// Analysts sometimes want to prune low-signal generated trees (a cluster of +// junk, an OU that doesn't model anything useful). Deleting is permitted +// but we call out the caveat: re-running the same plugin will re-create +// the row. For persistent removal, the user should tune plugin parameters +// (e.g., add a noise token to additionalStopwords). +function GeneratedContextActions({ contextId, attrs, authFetch, onDeleted }) { + const [confirming, setConfirming] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(null); + + async function doDelete() { + setDeleting(true); setError(null); + try { + const r = await authFetch(`/api/contexts/${contextId}`, { method: 'DELETE' }); + if (!r.ok && r.status !== 204) { + const body = await r.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${r.status}`); + } + onDeleted?.(); + } catch (err) { + setError(err.message || 'Delete failed'); + setDeleting(false); + } + } + + const algo = attrs.sourceAlgorithmDisplayName || attrs.sourceAlgorithmName || 'its plugin'; + + return ( +
      +
      +

      Generated context — actions

      + + Generated by {algo} + +
      +

      + Delete this context if it's noise. Re-running {attrs.sourceAlgorithmName || 'the plugin'}{' '} + with the same parameters will recreate it — to keep it gone, also tune the plugin + parameters (e.g., add noise tokens to additionalStopwords) + before re-running. +

      + + {error &&
      {error}
      } + + {confirming ? ( +
      + Delete this context and all its descendants + members? + + +
      + ) : ( + + )} +
      + ); +} + +function describeProvenance(attrs) { + if (attrs.variant === 'synced') { + const src = attrs.scopeSystemName ? `system ${attrs.scopeSystemName}` : 'an upstream crawler'; + return `Synced from ${src}. Updated by the next crawl; analyst edits do not persist.`; + } + if (attrs.variant === 'generated') { + const algo = attrs.sourceAlgorithmDisplayName || attrs.sourceAlgorithmName || 'a plugin'; + const sys = attrs.scopeSystemName ? ` on ${attrs.scopeSystemName}` : ''; + return `Generated by the "${algo}" plugin${sys}. Replaced by the next run of the same plugin.`; + } + if (attrs.variant === 'manual') { + return `Created manually${attrs.createdByUser ? ` by ${attrs.createdByUser}` : ''}. Edit name, description, parent, and owner in-place.`; + } + return null; +} diff --git a/app/ui/src/components/ContextsPage.jsx b/app/ui/src/components/ContextsPage.jsx new file mode 100644 index 000000000..cf37d1268 --- /dev/null +++ b/app/ui/src/components/ContextsPage.jsx @@ -0,0 +1,192 @@ +// Contexts tab — two-pane layout: left selector + right tree/list view. +// See docs/architecture/context-redesign-ui.md for the design. + +import { useMemo, useState } from 'react'; +import { useAuth } from '../auth/AuthGate'; +import { useContextRoots, useContextSubtree } from '../hooks/useContextTrees'; +import ContextTreeSelector from './contexts/ContextTreeSelector'; +import ContextTreeView from './contexts/ContextTreeView'; +import ContextListView from './contexts/ContextListView'; +import NewContextModal from './contexts/NewContextModal'; +import { variantMeta, targetTypeMeta } from '../utils/contextStyles'; + +export default function ContextsPage({ onOpenDetail, onNavigate }) { + const { authFetch } = useAuth(); + const { roots, loading: rootsLoading, error: rootsError, reload: reloadRoots } = useContextRoots(); + const [selectedRootId, setSelectedRootId] = useState(null); + const [viewMode, setViewMode] = useState('tree'); + const [newModalOpen, setNewModalOpen] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + // Auto-select the first root when roots load. + const effectiveRootId = useMemo(() => { + if (selectedRootId && roots.find(r => r.id === selectedRootId)) return selectedRootId; + return roots[0]?.id || null; + }, [roots, selectedRootId]); + + const { nodes, loading: subtreeLoading } = useContextSubtree(effectiveRootId); + const selectedRoot = roots.find(r => r.id === effectiveRootId); + + function open(id, name) { + onOpenDetail?.('context', id, name); + } + + // Delete an entire tree (root + all descendants via ON DELETE CASCADE). + // Manual + generated allowed; synced is rejected by the API. After delete, + // we reload the roots list and clear the selection so the right pane + // gracefully falls back to the next available tree. + async function deleteTree(rootId) { + setDeleteError(null); + try { + const r = await authFetch(`/api/contexts/${rootId}`, { method: 'DELETE' }); + if (!r.ok && r.status !== 204) { + const body = await r.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${r.status}`); + } + setSelectedRootId(null); + reloadRoots(); + } catch (err) { + setDeleteError(err.message || 'Delete failed'); + } + } + + return ( +
      +
      +
      + setNewModalOpen(true)} + loading={rootsLoading} + /> +
      + +
      + {rootsError && ( +
      + {rootsError} +
      + )} + + {!selectedRoot && !rootsLoading && ( +
      + Select a tree on the left, or click + New to create one. +
      + )} + + {selectedRoot && ( + <> + + {subtreeLoading ? ( +
      Loading subtree…
      + ) : viewMode === 'tree' ? ( + + ) : ( + + )} + + )} +
      +
      + + setNewModalOpen(false)} + onCreated={(created) => { + reloadRoots(); + if (created?.id) onOpenDetail?.('context', created.id, created.displayName); + }} + onRunStarted={(runId) => { + if (runId) onOpenDetail?.('run', runId, 'Plugin run'); + }} + onOpenCrawlers={() => onNavigate?.('admin')} + /> +
      + ); +} + +function SelectedRootHeader({ root, viewMode, onChangeViewMode, onDeleteTree, deleteError }) { + const v = variantMeta(root.variant); + const t = targetTypeMeta(root.targetType); + const [confirming, setConfirming] = useState(false); + // Synced trees come from a crawler — deleting them via the API would + // let them re-appear on the next sync, so the API blocks it. + const canDelete = root.variant !== 'synced'; + return ( +
      +
      +
      +
      +
      + {root.description &&

      {root.description}

      } +
      + +
      + {canDelete && !confirming && ( + + )} + {canDelete && confirming && ( +
      + + Delete the entire "{root.displayName}" tree ({root.totalMemberCount ?? 0} members)? + + + +
      + )} + +
      + + +
      +
      +
      + + {deleteError && ( +
      + {deleteError} +
      + )} +
      + ); +} diff --git a/app/ui/src/components/CorrelationWizard.jsx b/app/ui/src/components/CorrelationWizard.jsx index e9d515000..097538ea0 100644 --- a/app/ui/src/components/CorrelationWizard.jsx +++ b/app/ui/src/components/CorrelationWizard.jsx @@ -65,13 +65,13 @@ export default function CorrelationWizard({ onClose, onSaved }) { }, [authFetch]); if (llmReady === null) { - return
      Loading…
      ; + return
      Loading…
      ; } if (!llmReady) { return (
      -
      +
      No LLM provider is configured yet. Open Admin → LLM Settings to add credentials, then come back.
      @@ -176,19 +176,19 @@ export default function CorrelationWizard({ onClose, onSaved }) {
      {/* Progress bar */} -
      +
      {STEPS.map((s, i) => { const done = i < stepIdx; const active = i === stepIdx; return (
      -
      -
      +
      +
      {done ? '✓' : i + 1}
      {s.label}
      - {i < STEPS.length - 1 &&
      } + {i < STEPS.length - 1 &&
      }
      ); })} @@ -230,10 +230,10 @@ export default function CorrelationWizard({ onClose, onSaved }) {
      {/* Footer buttons */} -
      +
      @@ -241,7 +241,7 @@ export default function CorrelationWizard({ onClose, onSaved }) { {stepIdx > 0 && !savedRulesetId && ( @@ -250,7 +250,7 @@ export default function CorrelationWizard({ onClose, onSaved }) { @@ -259,7 +259,7 @@ export default function CorrelationWizard({ onClose, onSaved }) { @@ -268,7 +268,7 @@ export default function CorrelationWizard({ onClose, onSaved }) { @@ -285,41 +285,41 @@ function SourcesStep({ domain, setDomain, orgName, setOrgName, hints, setHints, return (
      - + setDomain(e.target.value)} placeholder="example.com" - className="w-full px-3 py-2 border rounded" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500" />
      - + setOrgName(e.target.value)} placeholder="Acme Corporation" - className="w-full px-3 py-2 border rounded" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500" />
      - +
      Name Email Job Title Type Status
      {m.displayName}{m.email || '-'}{m.jobTitle || '-'}{m.displayName}{m.email || '-'}{m.jobTitle || '-'} {m.principalType && ( - {m.principalType} + {m.principalType} )} {m.accountEnabled != null && ( - + {m.accountEnabled ? 'Active' : 'Disabled'} )} + {includeDescendants ? ( + + ) : ( + + )} +