Skip to content

feat(tracker): issue access management — grants panel, access groups, level-aware enforcement (stacked on #10895)#10966

Draft
MichaelUray wants to merge 65 commits into
hcengineering:developfrom
MichaelUray:feat/issue-access-management
Draft

feat(tracker): issue access management — grants panel, access groups, level-aware enforcement (stacked on #10895)#10966
MichaelUray wants to merge 65 commits into
hcengineering:developfrom
MichaelUray:feat/issue-access-management

Conversation

@MichaelUray

@MichaelUray MichaelUray commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Issue Access Management

Fine-grained, fail-closed access management for Tracker issues on top of the
mention-grants mechanism from #10895: explicit per-issue grants with access
levels, reusable access groups, and a full management UI.

Stacked on #10895 — the leading commits in this PR belong to #10895
(mention grants) and are reviewed there. Please review only the commits
after 93c13b4873
(the current head of feat/huly-mention-grants). I
will rebase this PR the moment #10895 lands, which shrinks the diff to the
feature commits automatically.

What it does

  • P1 — Collaborator provenance + guard: Collaborator records gain
    provenance (who granted, and why) and an access-level field; a new
    fail-closed CollaboratorGuardMiddleware (registered after
    GuestPermissions) vetoes every access-granting Collaborator write the
    actor is not entitled to make. Includes an ordinal access-level helper as
    the evolution seam towards role mapping.
  • P2 — Level-aware enforcement: per-document collaborator visibility for
    all non-admin roles, and level-aware field-write enforcement
    (read < write < admin) in the security middleware.
  • P3 — Issue Access panel: a panel on the issue showing every grant with
    provenance and level, with grant/revoke management and a confirm dialog
    (fully i18n-ized).
  • P4 — Access groups: AccessGroup + GroupGrant classes with guarded
    writes, server-side grant materialization with reconcile triggers, and a
    management UI under Settings. Deleting a group tears down its materialized
    group collaborators.
  • P5 — Mention grants as Grant v2: mention-based grants become a special
    case of the same grant pipeline, with a fail-closed mention-consent UI and
    a pure reconcile helper covered by unit tests.

Security model

  • Fail-closed everywhere: unknown class, missing space, or missing grant
    always denies; the guard vetoes rather than filters.
  • Access levels: read / write / admin with ordinal comparison —
    field writes require write, grant management requires admin.
  • Provenance: every Collaborator row records how it came to exist
    (direct grant, group materialization, mention), so grants stay auditable
    and reconcilable.
  • Review remediation is included: C-01 (cross-space privilege escalation in
    the collaborator guard), M-01 (collab-read widening scoped to grant classes
    only), M-02 (group-collaborator teardown on AccessGroup delete), H-02
    (jest wiring so the client test runs in CI), L-02 (grant hint externalized
    to IntlString, EN + DE).

Known follow-ups (deliberately out of scope)

  • Performance: a composite index on (workspaceId, collaborator, attachedTo)
    for the collaborator lookup path — planned as a separate migration PR.
  • M-03 residual risk (documented during review): one server-trigger edge path
    attributes grants to the system actor instead of the originating user;
    tracked for a follow-up hardening pass.
  • Live end-to-end runs for the panel flows (P3.4/P4.4, Playwright) are still
    pending on an isolated staging stack; unit and integration coverage of the
    security paths is complete (RED→GREEN).

All feature commits are signed off (DCO). Fork-internal full CI (build,
svelte-check, unit tests, UI tests) is green for this exact head.

Adds a model-level boolean that classes can set alongside provideSecurity
to indicate that @-mentions in chat/activity messages on docs of this
class should auto-create Collaborator records, granting the mentioned
user explicit, disclosed access.

The flag has no effect unless provideSecurity is also true. provideSecurity
itself is unchanged (read-visibility OR-branch through the SpaceSecurity
middleware). Without mentionsGrantAccess, todays "mention is a no-op
silently" behavior is preserved for QMS and Love classes.

Step A1 of the plan in /opt/infrastructure/docs/superpowers/plans/
2026-05-25-huly-mention-grants-collaborator-access.md. The accompanying
model wiring (A2), Tracker opt-in (A3), middleware veto (A4), shared
helper (B-1), chunter trigger update (B-2), and client surfaces (B.1,
B.2, C) follow in subsequent commits.

Refs hcengineering#10783, hcengineering#9741.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit fcc426f)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…odel class

Mirrors the new field in the runtime model schema. Follows the existing
bare-annotation pattern used for the other ClassCollaborators fields
(no @prop decorators on this model class today).

Field is model-internal — never appears in user-facing labels, so no
IntlString or locale entries needed.

Step A2.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 9135923)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…+ mentions-grant-access

Sets provideSecurity:true and mentionsGrantAccess:true on the Issue
ClassCollaborators declaration. This is the single class opting into
the new behavior in this PR — QMS, Love, Cards and other classes are
unaffected.

Effect together with subsequent commits:
- A user @-mentioned on an Issue they are not a project-member of is
  auto-added as Collaborator on the Issue (B-2 chunter trigger).
- The Collaborator record grants them read visibility (provideSecurity
  in SpaceSecurity middleware).
- They can post comments (chunter.class.ChatMessage createAccessLevel
  is already Guest).
- They cannot edit Issue fields (A4 GuestPermissions middleware veto).

Step A3.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit bbf25b6)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…lper

Walks a Docs attachedTo chain (depth-capped at 8) to find the nearest
ancestor whose ClassCollaborators has both provideSecurity:true AND
mentionsGrantAccess:true. Returns that ancestor as the grant target,
or null if none.

Isomorphic via the findAll dependency injection — same code used by
both the server-side chunter mention-trigger (B-2) and the client-side
mention warning popup (C), so the disclosure UX matches the actual
server-side grant.

For ThreadMessage mentions: walks from ThreadMessage -> parent
ChatMessage -> parent Issue, and writes Collaborator on the Issue,
not on the thread or chat message.

Step B-1.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit f7b9a99)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…ly guests on opt-in classes

Adds isForbiddenCollabOnlyGuestFieldUpdate to GuestPermissionsMiddleware
as a class-agnostic, pre-commit veto. Triggered only when the targeted
class has both provideSecurity:true AND mentionsGrantAccess:true on its
ClassCollaborators model entry. Tracker Issue is the first opt-in
(set in models/tracker/src/index.ts), but any future class with the
same flags inherits identical semantics with zero additional code.

Semantics:
- User+ accounts pass through (unchanged).
- Space-member guests pass through (their existing rules apply).
- Guest-tier accounts that are NOT space-members but reach the doc via
  Collaborator status (provideSecurity OR-branch in SpaceSecurity
  middleware) get a Forbidden when they try to TxUpdateDoc the doc.

Effect for the user-visible scenario in hcengineering#10783:
- Florian @-mentioned on GAME-4 in a project he is not a member of
  becomes Collaborator on GAME-4 (auto, via B-2 chunter trigger).
- He gets read visibility (provideSecurity).
- He can post comments (ChatMessage createAccessLevel: Guest).
- He CANNOT modify GAME-4 (status, title, dates, ...) — this veto.
- Florian editing his own OSKOS-12 in Ostrowo where he IS a member is
  unaffected: space.members.includes(florian) → veto returns false.

Step A4. Refs hcengineering#10783, hcengineering#9741.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 8e825a5)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…CommentOnIssue

Previously canEditIssue conflated "may edit fields" with "may comment".
A guest who was Collaborator on an issue ended up with all field
editors enabled (too permissive) — or, when blocked, lost the comment
composer too (too restrictive).

The split:
- canEditIssueFields: Issue field editors (title, status, dates,
  description, ...). Returns false for any guest-tier account.
- canCommentOnIssue: Comment composer. Returns true for User+, false
  for ReadOnlyGuest, and for Guest/DocGuest true when the user is the
  Issues creator OR listed as Collaborator on the issue.

Existing canEditIssue is kept as a backwards-compat alias for
canEditIssueFields until callers are migrated. EditIssue.svelte
already migrates in the next commit (B.2).

Pairs with the server-side veto in A4 (GuestPermissions middleware)
that rejects TxUpdateDoc<Issue> for collab-only guests at the tx
layer — so a guest cannot route around this UI gate via raw API.

Step B.1.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit b49c7c5)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…editors

EditIssue.svelte used a single effectiveReadonly flag to gate both the
issue-field editors (title, status, dates, description, dependencies,
...) AND the Panels comment composer below them. With the v6 split a
Collaborator-Guest must be able to comment without unlocking the
editors, so the two need separate variables.

Adds canComment alongside effectiveReadonly:
- effectiveReadonly stays driven by canEditIssueFields() — Guest gets
  read-only field editors.
- canComment is driven by canCommentOnIssue() — Guest who is the
  Issues creator or listed as Collaborator gets the comment composer.

Panels withoutInput now reads !canComment; all field-editor readonly
props keep using effectiveReadonly.

Step B.2.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 7449ad0)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
ChatMessageInput.svelte imports extractReferences from text-core for
the mention warning popup. The package was already an indirect dep via
text-editor-resources but webpack module resolution requires it as a
direct entry in package.json. Added; pnpm-lock regenerated via rush
update.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 7c58a56)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…nt-target dedup

Replaces the prior provideSecurity!==true guard with three explicit
branches driven by resolveMentionGrantTarget():

1. grantTarget non-null (opt-in class found via attachedTo chain):
   write Collaborator records on the grant-target Doc with dedup
   against the GRANT-TARGETs collaborator list, not the original
   message Docs list. Fixes the thread-reply case where the mention
   would otherwise land on the parent ChatMessage instead of the Issue.

2. grantTarget null + targetDoc not provideSecurity: keep todays
   notification-routing behavior on targetDoc (Channels, DMs).

3. grantTarget null + targetDoc provideSecurity (without opt-in):
   no-op. Preserves QMS / Love behavior bit-for-bit.

Step B-2. Refs hcengineering#10783, hcengineering#9741.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit ad78e4e)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
… access

Disclosure UX for the mention-grants-access flow. Before submitting a
message containing @-mentions, check whether the resolved grant-target
Doc (via the shared resolveMentionGrantTarget helper) has those
mentioned users in its space.members list. If any mention would grant
new access, show a MessageBox warning with the actor and the names of
the new grantees, and require explicit confirmation.

Important framing:
- This is disclosure UX for the standard client, NOT a security gate.
  Access is enforced server-side by SpaceSecurityMiddleware + the
  chunter trigger that creates Collaborator records. A scripted API
  client can still bypass the dialog.
- For thread replies: the grant-target resolves to the root Issue, so
  the warning correctly names the project the user would gain access
  to (not the thread).
- For Channels and other non-opted-in classes: grantTarget is null,
  no warning shown — preserves todays behavior bit-for-bit.

Step C. Refs hcengineering#10783, hcengineering#9741.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit ab212db)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…cross spaces

SpaceSecurityMiddleware.findAll() pre-filtered every query by the
caller's allowed-spaces set, which always strips docs in foreign spaces
before the database adapter ever sees the query. That defeated the
Postgres adapter's `collabRes` OR-branch (see postgres/src/storage.ts,
getSecurityClause): a Guest who is a Collaborator on a single Issue in
a project they are not a member of would never receive that Issue, even
though the adapter has the SQL to surface it.

This change skips the middleware-level space filter when:
  - the target class has ClassCollaborators.provideSecurity === true
    or provideAttachedSecurity === true, AND
  - the caller's role is Guest or ReadOnlyGuest.

For those calls the Postgres adapter still applies its
space-membership clause and additionally OR-joins Collaborator records,
giving the user visibility on the individual docs they were added to
(directly via provideSecurity, or via their parent via
provideAttachedSecurity for ActivityMessage / DocUpdateMessage).

All other roles (User, Maintainer, Owner, Admin, DocGuest, System)
take the existing code path unchanged.

Note for non-Postgres adapters: the Mongo adapter does not currently
implement the collab OR-branch, so this bypass only takes effect on
Postgres deployments (the supported production target). On Mongo the
visibility behavior is unchanged from before because there was no
collab OR-clause to fall through to.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 5db9184)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…or records

The tracker "Subscribed" tab in the client queries
`findAll(Collaborator, { collaborator: self, attachedToClass: Issue })`
to build the user's subscription list. Until now that query was
silently filtered to spaces the user is a member of, so a Guest who
became a Collaborator on an Issue in a project they are not a member
of (e.g. via the new mentions-grant-access flow) saw an empty
Subscribed list.

This change introduces a self-Collaborator visibility rule consistent
across the two enforcement layers:

  - foundations/server/packages/postgres/src/storage.ts
      `addSecurity` now appends `OR <domain>.collaborator = '<acc>'`
      whenever the queried domain is DOMAIN_COLLABORATOR. The user can
      always see their own Collaborator rows.

  - foundations/server/packages/middleware/src/spaceSecurity.ts
      `findAll` skips its space pre-filter when the requested class is
      core.class.Collaborator (`selfCollabBypass`), so the Postgres
      adapter actually receives the query and can apply its self-row
      OR-branch. Other rows in the same workspace remain hidden by the
      space-membership clause that still runs first.

Scope: applies to all non-Admin/non-System callers (Users included).
Users were not visibly affected before because they are typically
members of every space whose docs they collaborate on, but the rule
is the same: you may read your own subscription rows.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 51fa7ba)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…r Guests

A Guest who is a Collaborator on a doc inside a Space they are not a
member of (e.g. via the new mentions-grant-access flow) could open
the doc directly via URL and see it listed in tracker "Subscribed",
but the containing project never appeared in the "Your Projects"
nav tree because that tree only listed member-spaces.

This change extends visibility on three layers:

  - foundations/server/packages/postgres/src/storage.ts
      `addSecurity` adds an `OR EXISTS (collaborator c WHERE c.space =
      space._id AND c.collaborator = '<acc>')` branch for Guest/
      ReadOnlyGuest reads against DOMAIN_SPACE. A Space hosting any
      Collaborator record naming the caller becomes readable.

  - foundations/server/packages/middleware/src/spaceSecurity.ts
      `findAll` skips its space pre-filter when the target class is a
      Space and the caller is Guest/ReadOnlyGuest (`spaceCollabBypass`),
      so the new Postgres OR-branch actually fires.

  - plugins/workbench-resources/src/components/Navigator.svelte
      A second query against Collaborator (scoped to self by A6's self-
      collab visibility rule) collects the unique `space` IDs of the
      caller's Collaborator records, fetches those Spaces narrowed to
      the navigator's class set, and merges them into the displayed
      spaces alongside member-spaces. Existing member-spaces semantics
      for User+/Admin accounts are unchanged: the second query is
      skipped for admins, and `members: <self>` stays on the primary
      query for everyone, so public-but-not-member projects are not
      surfaced as a side effect.

Clicking a collab-only project still opens the existing Issues view,
which now shows only the docs the user can actually see (member-or-
collab-on-doc) thanks to the earlier A5/A6 work. Components, Milestones
and Templates sub-nodes will show empty for collab-only projects.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 64105d1)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
The workbench Navigator already fetched collab-only Spaces and merged
them into the spaces array, but each SpacesNav then ran its
`visibleIf` resource filter against the entries — and tracker's
`IsProjectJoined` only returned true for members. The result was that
collab-only projects were correctly fetched, hashed and passed to the
component, then immediately filtered back out before render.

Changes:

  - plugins/workbench-resources/src/components/Navigator.svelte
      Refactor activeClasses into a top-level reactive declaration so
      Svelte tracks it as a dependency in the downstream collab-space
      query. Drop the diagnostic console.log lines that helped track
      this down.

  - plugins/tracker-resources/src/index.ts
      Extend IsProjectJoined: return true also when the caller has any
      Collaborator record attached to a doc inside this project. Reuses
      A6 self-collaborator visibility so the lookup works for Guests
      that are not space members.

Verified end-to-end against the dk3 test workspace: Florian (Guest,
collab on GAME-4 only) now sees the Game Design project in his Your
Projects tree, with the Issues special filtered down to only the docs
he is actually a Collaborator on. Components / Milestones / Templates
remain empty for the same caller.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 97b58bd)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
A9 follow-up to the mention-grants nav-tree work. When a Guest sees a
project in 'Your Projects' because they are a Collaborator on a doc
inside it (A7) — or any non-member who lands a project in the tree
via the new IsProjectJoined fallback (A8) — only the Issues sub-node
has anything to render. Components, Milestones, and Templates have
no provideSecurity opt-in, so the postgres collab-OR-branch does not
fire on them and the queries come back empty. The user sees three
silent dead-end entries.

Fix in ProjectSpacePresenter: derive isCollabOnlyProject from
space.members membership and filter the specials down to id ==
'issues' when the caller is not a member. Members keep all four
sub-nodes; the visibleIf chain for collab-only projects keeps Issues
and nothing else.

This is a UI-only narrowing. Backend visibility is unchanged: A5/A6/A7
still control what the user can actually see when they navigate
elsewhere, the postgres adapter still does its filter, and the
middleware bypass remains scoped to provideSecurity classes. If a
future class opts into provideSecurity / provideAttachedSecurity for
a project sub-collection (Components etc), the filter here can be
relaxed without touching the underlying security model.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit c76073c)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Reuse the existing isCollabOnlyProject reactive to dim the icon (0.6
opacity) and expose a tooltip on projects the user only sees via a
mention-grant rather than membership. Tooltip uses the use:tooltip
action so the IntlString resolves internally (no Promise in an
attribute).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 2227bed)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Regression guard: a user mentioned in an issue comment stays a
Collaborator (the record that backs read access) after the issue is
moved to Done. Built on the sanity page-object helpers, mirroring the
existing mentions.spec assertion style. Guest-perspective UI walk is a
documented follow-up (needs a second auth storage state).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 27d2c58)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Add a string grantsAccess ('true'|'false'|undefined) to ReferenceMarkupNode
and surface it on extractReferences() with any-wins dedup (a person is
denied only if every reference to them is explicitly 'false'). undefined
preserves pre-V3 grant-by-default. Tiptap ReferenceNode keeps the
data-grants-access attribute across editor round-trips.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit e219510)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Move the grant decision from the context-free mention popup into the
send-time disclosure, which already knows the grant target, space
members and new grantees. The dialog shows a checkbox per new grantee;
unchecking rewrites every reference to that person in the message markup
to grantsAccess='false' (all of them, so any-wins cannot re-grant) via
the pure applyMentionGrantChoices helper. Existing members' mentions are
left untouched.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 49c5117)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
The mention-grants trigger now skips Collaborator creation for any
reference whose grantsAccess is explicitly 'false' (set by the V3b
send-time disclosure). undefined still grants (pre-V3 default). This is
the authoritative server-side enforcement; the client disclosure filter
is UX only. Covered by a ChunterTrigger unit test that asserts a denied
mention produces no Collaborator tx.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 1c8cd3e)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Add an OnChatMessageUpdated branch that re-runs the mention-grant logic
on an edited message's new text. It only ADDS Collaborator records
(existing ones dedup to no-ops) and never removes: TxUpdateDoc carries
no pre-update state to diff against, and Collaborator has no provenance
to safely remove mention-sourced grants by. Revoke-on-removal is
deferred to a future provenance model (shared with the rendered-mention
revoke stream).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 650ed5e)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
OnChatMessageUpdated rebuilt the message via { ...current, message } which
kept the original author's modifiedBy, so applyMentionGrants' System guard
checked the wrong actor. Use TxProcessor.updateDoc2Doc so modifiedBy
reflects the edit tx. Add ChunterTrigger unit tests for the update path:
new-mention grant, denied-mention (V3c) on edit, existing-collaborator
dedup, System-authored edit grants nothing, and non-message update no-op.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 7cb590f)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
V3b made the send flow async (the per-grantee disclosure popup), but
onMessage removed the draft and cleared the input BEFORE the popup
resolved and did not await handleCreate. Cancelling the grant dialog
therefore lost the unsent comment. handleCreate/handleEdit now return a
boolean; onMessage awaits both and only drops the draft + clears the
input + dispatches submit on a successful send/edit.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 34bc13a)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…covered-class test

isForbiddenCollabOnlyGuestFieldUpdate (added by 8e825a5) walks
the class hierarchy via getClassCollaborators → getAncestors.
The "uncovered class" test scaffold patched classHierarchyMixin
and isDerived but not getAncestors, so the new code path threw
"ancestors not found: test:class:UncoveredClass" before reaching
the production-style early-return.

Returning [] from the stub matches the intent: an uncovered class
has no ancestors to inherit ClassCollaborators from.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…<Class<Space>>[]

64105d1 introduced the activeClasses derived reactive but cast it
to Ref<typeof core.class.Space>[]. Since `typeof core.class.Space`
is already Ref<Class<Space>>, that double-wrapped the type to
Ref<Ref<Class<Space>>>[], which svelte-check correctly flagged as
incompatible with the collab/member query callsites that expect
Ref<Class<Space>>[].

Drop the extra Ref<> wrapper and import Class so the cast is to the
actually intended Ref<Class<Space>>[].

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Address findings from the independent self-code-review:
- B-NEW-2 (BLOCKER): Tiptap grantsAccess attribute parsed to null instead
  of undefined, violating the public type 'true'|'false'|undefined. parseHTML
  now normalises null -> undefined, default -> undefined. Plus the node-level
  renderHTML now explicitly emits data-grants-access (belt-and-suspenders).
- B-NEW-3 (BLOCKER): V1 emoji-iconned collab-only projects were not dimmed.
  The IconWithEmoji branch now also carries opacity: 0.6.
- B-NEW-1 (BLOCKER): sanity addMentions helper hung on the new V3b
  disclosure popup. It now confirms 'Send with selected grants' when the
  dialog appears (1s timeout for the legacy non-disclosure path).
- I-NEW-6 (IMPORTANT): V1 reactive miss — updateSpecials did not re-fire on
  isCollabOnlyProject changes, leaving stale specials when the user got
  added to/removed from the project mid-session. Pass collabOnly as a
  parameter so Svelte tracks the dep.
- I-NEW-7 (IMPORTANT): trigger test used require() which fails strict tsc
  (the package types only include @types/jest). Replaced with ESM import.
- I-NEW-8 (IMPORTANT): applyMentionGrantChoices guarded n.attrs !== undefined,
  which lets null through and throws on n.attrs.id. Use != null instead.

All 18 mention unit tests stay green (V3a 7/7, V3b helper 5/5, V3c+V3d
trigger 6/6).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 33fb480)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Sample/auto-templated tracker Projects store members=NULL in cockroach
(no explicit member array). The V1 isCollabOnlyProject expression did
'space.members.includes(uuid)' without a null guard, so it threw a
TypeError on those projects. Svelte's reactive block swallowed the
throw, leaving isCollabOnlyProject undefined -> the badge + sub-node
hide silently broke. Found in MentionTestWS 'Welcome to Huly!' and
test-workspace 'Game Design (Example)'. Fix: '(space.members ?? [])'.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
(cherry picked from commit 8299469)
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
The hard-coded 'sp.id !== "issues"' check in updateSpecials assumes
tracker.class.Issue is the only class with mentions-grant-access opt-in.
That holds today (only Issue carries ClassCollaborators.mentionsGrantAccess
= true). If a future change extends collab-grants to Components,
Milestones, or Templates, this check will silently hide those views for
collab-only Guests. Add a comment so the next person to flip an opt-in
sees the implicit dependency.

No behavioral change — comment only.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Die drei Collab-Read-Bypaesse in SpaceSecurityMiddleware.findAll
(collabReadBypass, selfCollabBypass, spaceCollabBypass) entfernten den
Space-Filter und ueberliessen die Einschraenkung einer Collab-OR-Klausel,
die ausschliesslich im Postgres-Adapter (storage.ts addSecurity) existiert.
Auf einem Backend ohne Aequivalent (Mongo hat keine adapter-seitige
Space-Security) fuehrte das Weglassen des Filters zu einem
Cross-Space-Leak: eine Guest-Query lieferte Dokumente aller Spaces.

Fix (Option A, fail-closed): neues Adapter-Capability-Flag
DbAdapter.supportsCollaboratorSecurity. Der Postgres-Adapter setzt es
(true), Mongo nicht (undefined). Die Middleware ermittelt ueber
adapterManager.getAdapterByName(getAdapterName(domain)) das fuer die
Domain zustaendige Backend und gated alle drei Bypaesse hinter einem
zentralen isCollabBackendSupported-Check. Unbekanntes/nicht
unterstuetztes Backend => Bypass greift NICHT, der normale Space-Filter
bleibt erhalten (kein Leak; das Cross-Space-Collab-Feature ist auf Mongo
dann inaktiv, akzeptabel da Mongo in v7 deprecated).

TDD: collabSpaceSecurity.test.ts fuehrt findAll gegen eine gemockte
Pipeline und prueft die ausgehende Query je Bypass fuer beide Backends
(supported => Filter faellt weg; unsupported/kein AdapterManager =>
Filter bleibt, S2 ausgeschlossen). 7 Tests, gesamtes middleware-Package
37/37 gruen.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…es (P2.2)

Widen the collab-read bypass (spaceSecurity.ts collabReadBypass /
spaceCollabBypass) and the Postgres addSecurity collab OR-branch from
Guest/ReadOnlyGuest to every non-admin, non-DocGuest role, so a grant to a
regular member (not only a guest) actually makes the doc visible.

Built on the H5-A fail-closed backend guard (a8bcff9): the widened bypass
stays gated behind isCollabBackendSupported / supportsCollaboratorSecurity, so
on a backend without an adapter collab-branch (Mongo) the bypass does not fire
and the normal space filter remains - no cross-space leak.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
… (P2.3)

isForbiddenCollabOnlyGuestFieldUpdate becomes level-aware and applies to every
collab-only non-member, not just guests:

- read grant (or none) vetoes TxUpdateDoc field writes; write/admin frees them
  (via hasAtLeast, ordinal read<write<admin from P1); a person's highest grant
  across records wins (some(hasAtLeast)).
- removes of the target doc stay vetoed regardless of level (delete is reserved
  for space members/owners) — preserves L-RM.
- a regular User is only collab-only on a PRIVATE space; public-space members
  keep normal access (no regression). Maintainer+ and space members pass.
- L-GP fail-closed (unresolvable space forbids) preserved.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Level-aware confirmation dialog (Read/Write/Admin) reused by the manual
per-issue grant flow, plus the IntlString declarations and EN/DE lang
entries for the Access panel. Warning wording is level-dependent per
design 2.6.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…nagement (P3.1/P3.3)

Adds IssueAccessPanel to the issue aside, replacing the generic
notification Collaborators mixin for issues in ControlPanel. Shows
read-only project members plus an 'additional access' list of granted
Collaborator records with provenance labels (mention/manual/group) and
per-grant access level.

Manual grants: add-person picker + level-aware confirm, revoke, and
in-place level change (remove+create, since grant records are immutable
per P1). Self-decline for the grantee. Level dropdown is editable only
for manual grants; mention/group levels are shown read-only. All grant
authority is enforced server-side by CollaboratorGuardMiddleware (P1.2);
this UI gating is courtesy only. Write failures surface via
setPlatformStatus, never silent.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
… writes

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…uard

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Integrate the existing mention-grant trigger with the P1-P4 grant
infrastructure:
- provenance: grant path writes grantedVia:'mention' + grantedBy +
  grantedByMessage + level:'read' (P1 schema). Dedup basis is now per
  (collaborator, mention, message), not global — a structural/manual
  collaborator still gets a revocable mention record.
- fail-closed consent (M-G.2): only grantsAccess === 'true' grants;
  the notification fan-out for non-secured targets keeps a separate
  (!== 'false') list so channel mentions still notify without granting.
- author-membership gate (M-G.3): a mention only grants when the message
  author resolves to an account that is a member of the grant-target
  space — stops transitive spread by collab-only guests. Fail-closed on
  unresolvable author.
- revoke (M-G.4): OnChatMessageUpdated reconciles this message's mention
  records (added->create, removed->TxRemoveDoc); OnChatMessageRemoved
  tears down its grantedByMessage records. Only grantedVia:'mention'
  records with the matching grantedByMessage are touched.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…(P5.2/M-G.5)

- MentionGrantConfirm defaults every grantee row to unchecked; the
  disclosure text now states nobody is granted unless checked and that
  access is revocable from the item's Access panel.
- ChatMessageInput shows a dezent inline warning while typing whenever a
  non-denied person mention lands on a grant-target document, before the
  authoritative confirm dialog.
- client test: fail-closed default (all unchecked) yields no
  grantsAccess='true' references.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…r guard

Die Grant-Autorisierung pruefte gegen die client-gewaehlte objectSpace des
Collaborator/GroupGrant-Records, waehrend die Sichtbarkeitswirkung allein am
attachedTo-Doc haengt (Postgres addSecurity keyed collab_sec.attachedTo =
domain._id, ohne space-Abgleich). Ein regulaerer User konnte damit einen
Admin-Grant auf ein Issue in einem fremden privaten Space schreiben, indem er
den Record in einen eigenen Space legte.

Fix: neuer fail-closed Helper resolveTargetSpace laedt das attachedTo-Doc,
erzwingt objectSpace === doc.space und autorisiert gegen den ECHTEN Space des
Docs (checkCreate + checkGroupGrantTx). Nicht ladbar / Space-Mismatch -> reject.

Tests: 3 C-01-Repro-Faelle (manual, group, fail-closed unresolvable), 94/94 gruen.
Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Die P2.2-Rollen-Ausweitung des collabReadBypass feuerte fuer jede
provideSecurity-Klasse. love und controlled-documents (QMS) setzen
provideSecurity ohne mentionsGrantAccess -> ein regulaerer Member, der dort
nur strukturell Collaborator (createdBy/assignee) ist, erhielt neue
Cross-Space-Read-Sichtbarkeit ausserhalb des Feature-Ziels.

Fix: die Ausweitung auf Member-Rollen nur fuer mentionsGrantAccess-Klassen
(= Write-Veto-Scope); die urspruengliche Guest/ReadOnlyGuest-Sichtbarkeit
bleibt fuer alle provideSecurity-Klassen erhalten.

Tests: +2 (User ohne mentionsGrantAccess behaelt Space-Filter; Guest behaelt
Bypass), 96/96 gruen.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…elete

OnAccessGroupChanged behandelte nur TxUpdateDoc. Ein System-/Migrations-Delete
einer AccessGroup (der die CollaboratorGuard-Blockade umgeht) liess die
materialisierten grantedVia:'group'-Collaborators als Waisen zurueck -> Zugriff
blieb bestehen.

Fix: TxRemoveDoc-Zweig raeumt alle GroupGrants der Gruppe + deren abgeleitete
group-Collaborators ab (kein verwaister Zugriff).

Zusaetzlich: 6 vorbestehende tsc-Fehler in der P4-Trigger-Testdatei behoben
(inline Tx-Mock-Literale ohne Pflichtfelder -> : any), Paket kompiliert jetzt
sauber mit tsc. Tests 20/20 (inkl. M-02 Delete-Teardown).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…in CI

Das Paket hatte jest + jest.config.js, aber kein test/_phase:test-Script -> der
P5-Client-Test mentionGrants.test.ts (inkl. fail-closed Consent-Default-Assertion)
lief nie in rush test/CI (Scheinsicherheit). Scripts nach setting-resources-Muster
ergaenzt; Test laeuft jetzt (6/6).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Der Inline-Grant-Warnhinweis nutzte ein embedded Englisch-Literal
(getEmbeddedLabel) und wurde nie uebersetzt. Neue IntlString
chunter.string.MentionGrantsAccessHint mit EN+DE; ungenutzten
getEmbeddedLabel-Import entfernt. svelte-check + Client-Test (6/6) gruen.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
AccessGroup Members/Owners use the ArrOf value helper from @hcengineering/model
while TArrOf implements the ArrOf type from @hcengineering/core; importing both
as ArrOf collides (TS2300). Alias the model value as ArrOfProp. Surfaced by the
full CI type-check; local transpile-only builds did not catch it.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Add the 27 access-management string keys (P3 issue-access panel + P4
settings) to every non-EN/DE locale using the English value as fallback,
so the tracker-assets lang.test key-parity check (en vs ru) passes again.
DE stays translated; RU now matches EN key-for-key.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…t-compare)

Reformat access-management sources to prettier width and add explicit
comparators to bare Array.sort() calls in the access-group reconcile
tests (@typescript-eslint/require-array-sort-compare), so
rush fast-format produces no diff and the CI formatting gate passes.
No behavioural changes.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
Match the exact prettier-plugin-svelte and eslint-fix output CI produces:
expand three inline on:click arrow blocks in the access-management svelte
components, and drop redundant parens/semicolons in the collaborator-guard
test. Makes rush fast-format produce a zero diff on CI. No behaviour change.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…agement strings

Add the access-group settings keys (P4) and the mention-grant consent hint
(P5) to every non-EN/DE locale using the English value as fallback, so the
setting-assets and chunter-assets lang.test key-parity checks (en vs ru)
pass. DE stays translated.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
@huly-github-staging

Copy link
Copy Markdown

Connected to Huly®: UBERF-16641

The new panel shows project members and explicit additional grants; legacy
structural collaborators such as createdBy/assignee remain access-relevant but
are intentionally not rendered as managed grants.

- IssueAccessPanel: add stable ids (issue-access-panel / -members / -additional)
  and correct the misleading grants comment (no behaviour change).
- issues-details-page: retarget checkCollaborators to the new panel and add
  checkNotInAdditionalAccess; drop the obsolete Collaborators-popup selectors.
- mentions.spec: rewrite the assignee test to assert the assignee is NOT shown
  as an additional grant; structural access stays covered by the server guard
  tests (CollaboratorGuardMiddleware).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
@ArtyomSavchenko

Copy link
Copy Markdown
Member

Hi @MichaelUray
I think we don't need a separate access management mechanism for issues only. My worry is that we'll then resolve the same problem for every other entity and end up with several inconsistent permission mechanisms. I'd rather build one unified permission system and make issues its first consumer.

Roughly:

  1. Permissions are assigned to user groups, not individual users. Effective permissions are the union of a user's groups.
  2. Permission sets declare application-level access — which apps a group can reach and which it can't. Cheap first gate, and one obvious place to answer "what can this team see?"
  3. Access is also scoped to spaces within an application. A group shouldn't just have "access to the issue tracker" — it should have access to a specific set of spaces inside it. So a grant is really a triple: group → application → spaces, plus the permissions within them.
  4. Each application declares its permission model in its data model — which objects it exposes and what permission groups exist for them (e.g. issue: read, comment, transition, delete). The core system knows nothing about issues specifically; it just reads the declaration. That's what makes it extensible.
  5. Admin flow becomes: pick a group → pick an application → pick spaces → check off the permissions that app declared. No custom code per app.

…alation (H-NEW-01)

A plain space member (role >= User) could self-attribute a manual Collaborator
with level 'admin'; hasAdminGrant() then counted it, unlocking canGrantGroup()
and foreign-revoke — powers reserved for owners/maintainers/admin-grantees.
checkCreate now caps 'admin'-level grants to callers who already hold
admin-equivalent authority (canGrantAdminLevel = canGrant minus the member path).
Adds guard tests for the self-mint rejection and the owner/admin-grantee paths.

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
…t guests (C-02)

GuestPermissionsMiddleware.tx() early-returned for role >= User before the
level-aware veto (isForbiddenCollabOnlyGuestFieldUpdate) could run — it lived
inside processTx on the guest-only path. A regular User who reached an issue in
a private project only through a read-level collaborator grant could therefore
edit its fields, so the read/write level was unenforced for non-guests.
The veto (which already passes space members and Maintainer+ by construction) is
now extracted to checkCollabOnlyGrantVeto and run for every account at the top of
tx(). Adds pipeline-level tests over tx() (the P2.3 tests only called the private
method directly, which is why they missed this).

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
@MichaelUray

Copy link
Copy Markdown
Contributor Author

Thanks @ArtyomSavchenko — I agree with the direction. One correction to my own earlier framing first: I no longer think this should land as an issue-specific permission mechanism. Issues should be the first consumer of a generic object-permission layer, and I'd rather build toward that than ship an issue-only feature.

What helped me get there is how much of it already exists upstream. Permission already carries txMatch, objectClass, scope and forbid; Role.permissions bundles them; and SpaceTypeDescriptor.availablePermissions + ModulePermissionGroup already let an app declare its own permission model — which is exactly your point 4. On the data side, Collaborator is already shaped like a relationship tuple (object, subject), and addSecurity() already has a Collaborator branch that grants per-document visibility — today gated to guests and to classes with provideSecurity. So I read your proposal less as "build a new system" and more as "consolidate and generalize what's already implicit in the code."

The one hard constraint that shapes everything: read security is compiled into SQL. addSecurity() appends the visibility predicate before ORDER BY/LIMIT (and to the COUNT), so pagination and totals run over the filtered set. Any model has to keep the visible set expressible as a SQL predicate. The invariant I'd propose: read is a SQL-compilable existence check; all per-action granularity lives on the write path. A grant's existence makes the object visible; whether the subject may edit vs comment vs transition would be decided per-transaction in the guard — Permission.txMatch plus a small anchor resolver, so a comment on an issue authorizes against the parent issue's grant.

Two concrete requirements from my use case: access to a single issue and nothing else in the project (my branch extends the existing addSecurity() Collaborator branch to issues and to all roles, which I think proves the read-side shape works); and different actions per user on the same issue — some may edit, others only comment. The ordinal level I used in my draft can't express that; declared per-action permissions can, which is another argument for your model.

On the grant shape, I'd evolve Collaborator with role?: Ref<ObjectRole> (a named, app-declared permission set) rather than introduce a parallel grant document up front — every hot path already keys on Collaborator. A canonical grant doc can come later if audit/expiry needs outgrow it. And I'd keep authorization in-process rather than introduce an external authz runtime, precisely because of the in-SQL read path — an external checker can't inject that predicate.

One lesson from validating my branch: I found and fixed locally two write-path inconsistencies in my own draft implementation, both caused by authorization logic being spread across middlewares with role-specific early exits. That reinforced your consolidation point — this belongs in one engine.

For sequencing I'd suggest starting small: a PR that only declares the model primitives (Permission.scope: 'object', ObjectRole, Collaborator.role, Tracker's issue permissions) with no behavior change, then wire enforcement behind the existing middlewares incrementally. Would you review a "model primitives" PR as the starting point — and does an object-level exception layer fit how you're picturing the space-scoped model?

Signed-off-by: Michael Uray <michaeluray@users.noreply.github.com>
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.

2 participants