Skip to content

Access Control v2 + detailed analytics + unified shell (2.1.0)#3

Merged
windischb merged 15 commits into
developfrom
feature/access-control-v2
Jul 19, 2026
Merged

Access Control v2 + detailed analytics + unified shell (2.1.0)#3
windischb merged 15 commits into
developfrom
feature/access-control-v2

Conversation

@windischb

Copy link
Copy Markdown
Contributor

Restricted documentation with permission groups, a much richer analytics dashboard, and a unified app shell. Backwards-compatible — existing products stay public; nothing changes until a product is marked restricted. Targets release 2.1.0.

Highlights

Access Control v2

  • ProductConfig.Restricted — a restricted product is hidden from non-grantees and returns 404 on unauthorized access (no existence leak); anonymous → login redirect, admins see all. Enforced in DocsRoutingMiddleware; products API / landing / llms.txt filter it out.
  • Permission groups (Group): explicit email members (stageable pre-login) and/or JsEval auto-membership over the persisted login claims snapshot (Cocoar.JsEval.Engine, in-memory copy+adapt from timetodo, fail-closed). IsAdminGroup grants adminship.
  • Product-side grants: Group and UserDocument are both IPrincipal; a product's ReadPrincipals assigns groups and individual users (by email). IAccessResolver (per-request, memoized) — no JsEval in the hot path.
  • Security fix (Block 1): cookie product writes now require admin (previously any authenticated user could write). Bearer API-key path unchanged.
  • Groups admin page (grid + tabbed modal with Monaco script editor + dry-run); product Access tab; /_api/groups/* + /_api/principals.

Detailed analytics

  • Interactive world map of visitor cities (@cocoar/vue-map), country/city breakdowns with SVG flags (flag-icons — emoji flags don't render on Windows) + full names, browser/OS/language/referrer breakdowns, per-product + IP-exclude filters. All aggregation server-side; access log stores lat/lng.

Unified shell

  • Landing + admin share one AppLayout: same header everywhere (dark/light toggle), admin sidebar for logged-in admins on every page, dark mode on the landing.

Docs

  • CHANGELOG 2.1.0; CLAUDE.md; website admin-ui.md + authentication.md (also corrects stale 2.0 email-OTP docs → OIDC code flow).

Verification

  • Backend 124/124 (incl. new AccessControlTests covering gating, filtering, group adminship, direct-user grants, JsEval auto-membership end-to-end).
  • Format gates clean; client type-check + build green; VitePress build green.
  • Verified end-to-end in the browser against the dev stack (real modgud OIDC login): restricted gating, groups + auto-membership dry-run, product access picker, analytics map/flags/breakdowns/IP-filter, unified shell (anon/admin, dark mode).

Concept: Atlas shelf/concept-access-control-v2.

🤖 Generated with Claude Code

windischb and others added 15 commits July 18, 2026 23:21
…ck 1)

Cookie-authenticated product writes now require admin, closing the gap
where any JIT-provisioned modgud user could create/update/delete products.

- ApiKeyFilter: cookie path requires AdminCheck.IsAdmin; non-admin cookie
  session → 403. Bearer API-key path (CI/CD machine track) unchanged.
- SPA: /admin/products route gated with admin meta; Products sidebar link
  shown only to admins; Dashboard product affordances gated so non-admins
  don't dead-end into an admin-only surface.
- Tests: split CookieAuth_WorksForProductWrites into
  CookieAuth_AdminCanWriteProducts (201) and
  CookieAuth_NonAdmin_CannotWriteProducts (403), exercising the real RBAC path.

Concept: Atlas shelf/concept-access-control-v2 (enforcement points table).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…to-membership (Block 2a/2b)

Additive, no behavior change yet (no enforcement; Restricted defaults false).

Data model:
- Group Marten document (permission carrier): MembershipMode, MemberEmails,
  MembershipScript, ReadProducts, IsAdminGroup, materialized AutoMemberUserIds,
  MembershipLastError, soft-delete. Registered in the shelf schema.
- ProductConfig.Restricted flag, orthogonal to Visibility.
- UserDocument claims snapshot (Claims map + flattened Permissions + ClaimsUpdatedAt).
- IGroupService / MartenGroupService (soft-delete CRUD).

Auto-membership (JsEval, copy+adapt from timetodo, in-memory — no event sourcing,
no SQL translation):
- Cocoar.JsEval.Engine 4.1.0; sandboxed predicate over a `user` global
  (user.email / user.permissions / user.claims), fail-closed on any error.
- IGroupMembershipEvaluator (Evaluate → match + error) + JsEval impl.
- IGroupMembershipRecalculator: per-user (login), per-group (save), all (manual);
  materializes AutoMemberUserIds.
- ClaimsSnapshot extractor + reusable ModgudClaimsTransformation.ParsePermissions.
- LoginAccessProcessor: refresh snapshot + recompute; wired into the real OIDC
  callback (OnTicketReceived) and the test sign-in seam.

Full suite 110/110 green; format gates clean.

Concept: Atlas shelf/concept-access-control-v2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enforcement (per-request AccessResolver, no JsEval in the hot path):
- IAccessResolver: memoized per-request grants (isAdmin incl. IsAdminGroup,
  readable restricted products) from group membership (email ∪ materialized
  auto ids) + token permission + allowlist.
- DocsRoutingMiddleware gates restricted products: authenticated-no-grant → 404
  (no existence leak), anonymous HTML → /login redirect, anonymous asset → 401.
- Product API: GET /products hides restricted from non-members (admins see all,
  badged); GET /products/{p} + /versions → 404 for non-members; Create/Update
  accept Restricted; responses expose it.
- llms.txt index excludes restricted products.
- Admin policy, ApiKeyFilter cookie path and /auth/me now route through the
  resolver, so IsAdminGroup members are admins everywhere.

Groups API (admin-gated): CRUD, manual recalc, JsEval dry-run (test-script).

Tests: 13 AccessControlTests cover middleware gating (404/redirect/401), API
filtering, group adminship, and JsEval auto-membership end-to-end
(create auto group → matching user login → materialized grant → served).
Full suite 123/123; format gates clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d toggle (Block 2d)

- Groups page (admin-only, top-level nav): grid + routed tabbed modal
  (General / Members / Auto-membership / Products), mirroring the products
  view conventions (grid store, rowDataRef, routed fragment modal).
  - General: name, description, membership mode (Manual/Auto), IsAdminGroup.
  - Members: explicit email tag-chips + read-only resolved auto-members.
  - Auto-membership: monospace JS predicate editor, last-error note, and a
    "test against a user" dry-run (POST /groups/test-script).
  - Products: read-grant multi-select from the products list.
  - Toolbar "Recalculate" triggers manual membership recompute.
- Product modal: Restricted toggle (General tab) with an explanatory note
  linking to Groups; wired through create/update; grid gains a Restricted column.
- Users page: memberships column (groups a user belongs to) + IsAdminViaGroup,
  computed server-side in the users list endpoint.
- groups store + shelf-api methods + Group/Restricted TS models.
- Router: /admin/groups (admin meta + routed modal); sidebar Groups link.

Client type-checks + builds; backend suite 123/123; format gates clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the plain textarea with the Monaco-based CoarScriptEditor from the
separate @cocoar/vue-script-editor package (2.17.1 — not part of vue-ui):
TypeScript language mode, scriptMode (no body diagnostics), and a hidden
preamble declaring `user` so the editor gives IntelliSense on
user.email / user.permissions / user.claims. CSS wired via
@import "@cocoar/vue-script-editor/styles".

Verified locally against the dev stack (modgud OIDC login) with chrome-devtools:
groups page + nav gating, create modal + tab/mode switching, editor renders,
group create → grid, JsEval dry-run against the live backend (match true/false),
product Restricted toggle + grid column. Fixed an editor-height collapse
(height needs a unit: "180px", not "180").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ncipal)

Flips the read-grant direction to the product side, per review: instead of a
group listing its products, a restricted product lists the principals that may
read it. Both Group and UserDocument now implement IPrincipal, so either can be
assigned — matching timetodo's Principal model.

- IPrincipal (Group + UserDocument) + PrincipalKind + PrincipalRef value type
  (group by id, user by email — email lets a grant be staged before first login).
- ProductConfig.ReadPrincipals replaces Group.ReadProducts.
- AccessResolver: grants now carry (isAdmin, groupIds, email); CanRead(product)
  tests the product's ReadPrincipals against them — no group ReadProducts scan.
- GET /_api/principals: assignable groups + users for the product access picker.
- Product create/update accept + expose ReadPrincipals; group upsert drops
  ReadProducts. Enum JSON as strings (PrincipalKind → "Group"/"User").
- Tests: grants moved product-side; added RestrictedDocs_DirectUserGrant_IsServed.
  Full suite 124/124; format gates clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…istration

- Product modal Access section: when Restricted, a "Who may read" picker assigns
  principals — a searchable groups+users dropdown (from GET /principals) plus a
  free-email input to stage a user before their first login. Chips show kind icon.
- Group modal loses the Products tab; group grid loses the Read-grants column
  (grants now live on the product).
- Groups page moves under Administration (like Users): /admin/settings/groups
  sub-nav entry; top-level Groups sidebar link removed; header owned by the
  settings layout.
- shelf-api getPrincipals; Product.readPrincipals + PrincipalRef/Principal models.

Verified locally with chrome-devtools against the dev stack: Groups under
Administration, picker lists groups + users, assigning a group and a staged user
by email round-trips (product readPrincipals persists both kinds).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ct modal

The Restricted toggle + "Who may read" principal picker move out of the General
tab into a dedicated Access tab (General / Access / Tags & API / Versions),
giving access control its own space instead of a nested block at the bottom of
General. Behavior unchanged. Verified in the browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ode everywhere)

The public landing and the admin UI were two separate shells with different
headers; the landing also had no dark mode. Now both share one AppLayout:

- AppLayout (generalized from AdminLayout): the branded header is always present
  (logo, title via useUI, dark/light toggle); the right side shows the avatar
  menu when authenticated or a Sign-in button when anonymous. The admin sidebar
  renders only inside /admin (the landing has none).
- Router: landing (/) and the admin routes are children of AppLayout, so the
  whole SPA is one consistent view. AdminLayout removed.
- LandingView: drops its own custom header (moved to the shell), sets its title
  via useUI, and its CSS is migrated to vue-ui theme tokens so dark mode works on
  the docs index too.

Note: this is the SPA (landing + admin). The VitePress-rendered product docs
(/{product}/{version}/…) are separate static content with their own theme.

Verified in the browser: shared header on landing + admin, sidebar only in
admin, Sign-in button when anonymous, dark mode on the landing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ding)

The sidebar now renders whenever the user is a logged-in admin — on every page
including the public landing — instead of only inside /admin. Anonymous visitors
and non-admin users get no sidebar. The header logo reserves the sidebar width
accordingly. Verified in the browser (admin landing shows the sidebar).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…breakdowns, IP filter)

All analysis stays in the backend; the browser only renders. GetSummary now
computes, in one pass over the filtered entries:
- countriesCount / citiesCount, uniqueIps-per-day
- topCities (city + country code), topCountries (top 30)
- locations: geolocated points (lat/lng + city/country + count) for the map
- byBrowser / byOs (User-Agent classified server-side), byLanguage
  (Accept-Language primary tag), topReferrers (referer host)
- excludeIps filter (also on /visits) to hide specific IPs from every aggregate

GeoIpService.Lookup now also returns lat/lng; AccessLogEntry gains
Latitude/Longitude (Marten JSONB — no migration), set during log persistence.
Country stays the 2-letter ISO code (name + flag are derived client-side).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rebuilds AnalyticsView into a full dashboard rendering the backend aggregates:
- Filter bar: date range + product selector + IP-exclude input (all server-side).
- KPI tiles: visits, unique visitors, countries, cities, top product.
- World map (@cocoar/vue-map / Leaflet): one bubble per city, colored by volume
  bucket (low/mid/high) with a legend, plus a Top-Cities sidebar.
- Visits-by-day chart (SVG) with unique-visitor tooltip.
- Top Countries + Cities with flag emoji + full name (derived client-side from
  the ISO code via Intl.DisplayNames — display only, not analysis).
- Top Products/Pages and Browser/OS/Language/Referrer breakdowns.
- Access log: country column shows flag + full name.

Verified in the browser against seeded geo data: map bubbles, flags, all
breakdowns, and the IP-exclude filter (49 → 41 when excluding a city's IP).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dows)

Windows doesn't render regional-indicator flag emoji (it shows the bare letters),
so the flags were invisible there. Switch to flag-icons (SVG):
- geo.ts exposes flagClass(code) → "fi fi-at" instead of an emoji.
- TopList renders an optional flag prefix; Top Countries/Cities pass the code.
- Access log country column uses a cell renderer with the flag span.
- Import flag-icons CSS from main.ts (not a Tailwind @import) so Vite's asset
  pipeline rewrites the flag url()s to bundled paths — otherwise they resolved
  to a non-existent /flags/*.svg and 404'd.

Verified in the browser: real AT/US/GB/DE/JP flags now render in the lists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…set)

The "users-round" icon isn't in the app's icon set, so the Groups sub-nav item,
the group modal, and the product group-principal chip showed no glyph. Per the
timetodo convention (users / shield for permission entities), Groups now uses
"shield" (distinct from the Users item's "users"), the group modal matches, and
the product access chip uses "users" (group) vs "user" (single user).

Verified in the browser: the Groups sub-nav icon renders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…P login docs)

- CHANGELOG: 2.1.0 — restricted products, permission groups, product-side
  principal grants, detailed analytics dashboard, unified shell, and the
  admin-only product-write security fix.
- CLAUDE.md: access-control-v2 architecture, API surface (groups/principals,
  product writes require admin, /login server route), key files, design decisions.
- website: admin-ui.md gains the Access tab, an Access Control section (restricted
  products + groups + auto-membership) and the new Groups admin section; the
  Analytics section describes the map/flags/breakdowns/filters. authentication.md
  is corrected from the old email-OTP flow to the OIDC code flow, notes admin via
  group, that product writes require admin, and drops the removed /auth/otp
  endpoints. VitePress build is clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@windischb
windischb merged commit b2a932a into develop Jul 19, 2026
2 checks passed
@windischb
windischb deleted the feature/access-control-v2 branch July 19, 2026 09:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant