Skip to content

A delegated advisor is a leader in timeplanning - #1769

Merged
renemadsen merged 1 commit into
stablefrom
fix/delegated-advisor-is-a-leader
Oct 28, 2026
Merged

renemadsen merged 1 commit into
stablefrom
fix/delegated-advisor-is-a-leader

Conversation

@renemadsen

Copy link
Copy Markdown
Member

The bug

A partner advisor opens a customer's eForm tenant from my.microting.com already signed in. The tenant mints a credential-less delegated principal for them just in time: no role, no security group, and a synthetic email of the shape delegated-<digest>@delegated.invalid.

SiteScopeResolver.ResolveForCurrentUserAsync decides site scope in four steps, and a delegated principal fell through all of them:

  1. role admin — the advisor holds none
  2. security group "eForm admins" — the advisor is in none
  3. in "eForm users", not in "Kun tid", and no AssignedSites.IsManager row anywhere — a degenerate-state fallback that never fires in a real tenant, because customers do configure managers
  4. sdkDbContext.Workers by Email == currentUser.Email — a synthetic address can never match

So every advisor request ended at step 4's failure branch: the planning page answered ErrorWhileObtainingPlannings, and Sentry was sent a message on every single request.

The fix

An advisor acts across the customer's whole organisation on the partner's behalf and is never employed at one of its sites, so in timeplanning they are a leader — unrestricted site scope. Putting them in "eForm admins" was rejected: that group carries far wider privileges than this needs. It is therefore decided here, in the resolver.

The marker is the same one the host already uses in UserManagerLockoutExtensions.IsDelegatedPrincipalAsync: a UserLogins row whose LoginProvider is the delegated provider. Deliberately not the email domain (a convention, and spoofable) and not a null PasswordHash (plenty of ordinary accounts have none — the host already had to fix that same over-broad assumption once).

The check sits immediately above the worker-email lookup it guards, inside the same method, and returns. That is what removes both the error and the per-request Sentry noise, and it means the lookup is unreachable for a delegated caller.

Behaviour for everyone else is unchanged

The query runs only after the admin cascade has already returned, so an admin, an "eForm admins" member and an "eForm users" member in the no-manager fallback never execute it at all. A plain worker and a "Kun tid" device user execute one extra AnyAsync against UserLogins (indexed by UserId) that comes back false, and then take exactly the path they took before. No rule was altered.

The provider literal

"my.microting.com/delegated", from ExternalLoginProviders.MyMicrotingDelegated in the host's eFormAPI.Web. This plugin references Microting.EformAngularFrontendBase, not the host web project, so the literal is repeated in a single named constant whose doc comment names the source of truth. It is static readonly, not const, on purpose: a const is folded into the pinning test at compile time and would silently agree with whatever the value had been changed to.

A shared package would be the better long-term home. This is now the second byte-identical copy of a host provider literal (eFormAPI.Web's own comment notes the plugin already holds a copy of the plain my.microting.com one), and the only thing keeping them together is a pair of comments and a pinning test in each repo. The natural home is Microting.eFormApi.BasePn or Microting.EformAngularFrontendBase, both of which the host and every plugin already reference — a small ExternalLoginProviders type there would let the host, this plugin and the next one import the same symbol instead of restating it. Out of scope for a fix, but worth doing before a third copy appears.

Tests

eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SiteScopeResolverTests.cs — the resolver had no fixture of its own; this one covers the new case and keeps the four pre-existing branches beside it, so the new case cannot be shipped by loosening one of them.

Delegated:

  • unrestricted even when managers exist (so rule 3 demonstrably cannot be what passes — asserted as an explicit guard) and when the caller is in no security group at all
  • wins over the worker lookup: an advisor whose email does match a plain non-manager worker is still unrestricted, which proves the check runs first without depending on Sentry
  • no Sentry message and no ErrorWhileObtainingPlannings, asserted against a live control in the same hub — an ordinary caller with no worker row must still be reported, so a hub that failed to install fails the test instead of making it pass vacuously. Nothing leaves the process: BeforeSend drops every event and the transport is a no-op.

Negatives — only the UserLogins row counts:

  • a non-delegated user with an identical-looking synthetic email (and no password either) is not delegated
  • a user linked under the ordinary my.microting.com provider is not delegated

Regressions — unchanged behaviour:

  • the admin role is still unrestricted
  • an "eForm admins" member is still unrestricted
  • the "eForm users" / no-manager fallback still grants the whole board
  • an "eForm users" member with a manager present is still restricted to their own site
  • a "Kun tid" member is still restricted even with no manager anywhere (guarded, so only the exclusion can be what restricts them)
  • an ordinary worker is still restricted to their own site

Plus TheDelegatedLoginProviderLiteralIsPinned.

The fixture is registered in shard g of both dotnet-core-pr.yml and dotnet-core-master.yml (required by ShardCoverageTests); g was the fastest shard on the last green master run at 216s.

dotnet build is clean, with no new warnings from either changed file. Tests were not run locally — CI is the executor.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6

…aller

A partner advisor who opens a customer's tenant from my.microting.com is
signed in as a credential-less "delegated principal": no role, no security
group, and a synthetic email (delegated-<digest>@delegated.invalid).

SiteScopeResolver matched none of its first three rules in a real tenant —
customers do configure managers, so the degenerate-state fallback does not
fire — and then looked the caller up in sdkDbContext.Workers by email, which
a synthetic address can never match. The planning page answered
ErrorWhileObtainingPlannings and Sentry was sent a message on every single
request.

An advisor acts across the customer's whole organisation on the partner's
behalf and is never employed at one of its sites, so they are a leader here.
Adding them to "eForm admins" was rejected: that group carries far wider
privileges than this needs.

The marker is the one the host already uses in
UserManagerLockoutExtensions.IsDelegatedPrincipalAsync — a UserLogins row for
the delegated provider — not the email domain and not a null PasswordHash,
both of which are wrong for accounts that merely look alike. The provider
literal is repeated here because the plugin references
Microting.EformAngularFrontendBase rather than eFormAPI.Web, and it is static
readonly so a test can pin it; a const would be folded into the test at
compile time and agree with any drift.

The check sits immediately above the worker lookup it guards, so a delegated
caller returns before it and neither the error nor the Sentry message can be
reached. Every other caller takes exactly the path they took before: the
query runs only after the admin branches have already returned.

Tests: SiteScopeResolverTests covers the delegated case (unrestricted even
with managers present and with no group membership; wins over a worker row
that would otherwise narrow it; no Sentry event, proven against a live
control that still produces one), both negatives (a delegated-looking email
with no login row, and a row for the ordinary my.microting.com link
provider), the pinned literal, and all four pre-existing branches as
regressions. Sharded into the fastest CI shard in both workflows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
Copilot AI balanced review requested due to automatic review settings October 28, 2026 12:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The test fixture explicitly repeats NUnit’s inherited setup, duplicating database reset and migration work and overwriting a database context without disposing it.

1 open issue
What changed in this PR

This PR gives delegated partner advisors unrestricted site scope in timeplanning, avoiding failed worker lookups and repeated Sentry messages while preserving existing access rules.

Changes:

  • Recognizes delegated principals by their external-login provider before resolving workers by email.
  • Adds delegated-access, negative-case, Sentry, and regression tests.
  • Registers the new fixture in shard g of both CI workflows.
File Description
eFormAPI/​Plugins/​TimePlanning.Pn/​TimePlanning.Pn/​Infrastructure/​Helpers/​SiteScopeResolver.cs Adds the delegated-provider marker and unrestricted-scope branch.
eFormAPI/​Plugins/​TimePlanning.Pn/​TimePlanning.Pn.Test/​SiteScopeResolverTests.cs Adds coverage for delegated access and existing scope rules.
.github/​workflows/​dotnet-core-pr.yml Includes the fixture in PR CI shard g.
.github/​workflows/​dotnet-core-master.yml Includes the fixture in master CI shard g.

Review effort: Balanced

Comment on lines +71 to +73
await base.Setup();

_userService = Substitute.For<IUserService>();
@renemadsen
renemadsen merged commit 28307e1 into stable Oct 28, 2026
79 of 80 checks passed
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