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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ updates:
- dependencies
commit-message:
prefix: deps
ignore:
# Microsoft.OpenApi 3.x cannot be used while Microsoft.AspNetCore.OpenApi targets the 2.x
# object model. Its source generator assigns IOpenApiMediaType.Example, which became
# read-only in 3.0, so the build fails in generated code no edit here can reach:
# OpenApiXmlCommentSupport.generated.cs: error CS0200: Property or indexer
# IOpenApiMediaType.Example cannot be assigned to -- it is read only
#
# The direct reference exists only to clear GHSA-v5pm-xwqc-g5wc, which
# Microsoft.AspNetCore.OpenApi 10.0.10 reintroduces by pinning 2.0.0 transitively (see the
# comment in Pgan.PoracleWebNet.Api.csproj). Minor and patch updates inside 2.x still come
# through, so a later advisory is not masked.
#
# Drop this once Microsoft.AspNetCore.OpenApi ships a release built against 3.x -- at which
# point the direct reference should go too. See #702.
- dependency-name: Microsoft.OpenApi
update-types:
- version-update:semver-major
groups:
# One group for the whole .NET platform. These packages ship as a single versioned set:
# Microsoft.EntityFrameworkCore 10.0.x transitively requires Microsoft.Extensions.* at
Expand Down
13 changes: 11 additions & 2 deletions Applications/Pgan.PoracleWebNet.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,16 @@ public async Task<IActionResult> Me()
// service looked at admin_disable, so an existing token kept full read/write access for the rest of
// its 24-hour life, and a fresh login minted another one. The SPA signs out on 401, so answering
// that here ends the session on the next poll, the same way a deleted account does. See #597.
if (human.AdminDisable == 1)
//
// Not while impersonating. This 401 ends the CALLER's session, and under impersonation the caller is
// the admin: inspecting a blocked account signed them out of their own, and the SPA's 401 handler
// discards the stashed admin token with everything else, so there was no way back. Blocked is exactly
// the state an admin inspects an account to confirm -- lapsed subscriptions are why alerts stop --
// so it is returned as data instead. The SPA already renders a banner from adminDisable. See #706.
//
// The deleted-account 401 above deliberately still fires: there is no account left to show. The SPA
// drops an impersonating admin back to their own token on any 401 rather than ending the session.
if (human.AdminDisable == 1 && !this.IsImpersonating)
{
return this.Unauthorized(new { error = "This account has been blocked by an administrator." });
}
Expand Down Expand Up @@ -863,7 +872,7 @@ public async Task<IActionResult> Me()
// where treating unknown as false stripped admin for the rest of the session (#656); and an
// impersonation session, which AdminController deliberately mints with IsAdmin = false and which
// would otherwise be re-elevated by resolving the impersonated user's own roles (#663).
bool? resolvedAdmin = roles.Resolved && this.User.FindFirst("impersonatedBy") is null
bool? resolvedAdmin = roles.Resolved && !this.IsImpersonating
? roles.IsAdmin
: null;
userInfo.IsAdmin = resolvedAdmin ?? this.IsAdmin;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ public abstract class BaseApiController : ControllerBase
protected string UserId => this.User.FindFirstValue("userId") ?? throw new UnauthorizedAccessException();
protected int ProfileNo => int.Parse(this.User.FindFirstValue("profileNo") ?? "1", CultureInfo.InvariantCulture);
protected bool IsAdmin => this.User.FindFirstValue("isAdmin") == "true";

/// <summary>
/// True when the caller is an admin (or webhook delegate) inspecting somebody else's account, so
/// <see cref="UserId"/> names the account being looked at rather than the person looking.
/// </summary>
/// <remarks>
/// Only <c>GenerateImpersonationToken</c> sets the claim, and the JWT is signed, so an inspected
/// user cannot mint one for themselves. Every decision about the CALLER -- their admin rights,
/// whether their session survives -- must consult this before reading the effective id, or it
/// answers a question about the wrong person. See #663, #706.
/// </remarks>
protected bool IsImpersonating => this.User.FindFirst("impersonatedBy") is not null;
protected string Username => this.User.FindFirstValue("username") ?? string.Empty;
protected string[] ManagedWebhooks => this.User.FindFirstValue("managedWebhooks")
?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ public async Task<IActionResult> SwitchProfile(int profileNo)
// where treating unknown as false stripped admin for the rest of the session (#656); and an
// impersonation session, which AdminController deliberately mints with IsAdmin = false and which
// would otherwise be re-elevated by resolving the impersonated user's own roles (#663).
bool? resolvedAdmin = roles.Resolved && this.User.FindFirst("impersonatedBy") is null
bool? resolvedAdmin = roles.Resolved && !this.IsImpersonating
? roles.IsAdmin
: null;
var newToken = this._jwtService.GenerateTokenWithReplacedProfile(this.User, profileNo, resolvedAdmin);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ await this.WriteGeographyAsync(
// where treating unknown as false stripped admin for the rest of the session (#656); and an
// impersonation session, which AdminController deliberately mints with IsAdmin = false and which
// would otherwise be re-elevated by resolving the impersonated user's own roles (#663).
bool? resolvedAdmin = roles.Resolved && this.User.FindFirst("impersonatedBy") is null
bool? resolvedAdmin = roles.Resolved && !this.IsImpersonating
? roles.IsAdmin
: null;
var newToken = this._jwtService.GenerateTokenWithReplacedProfile(this.User, this.ProfileNo, resolvedAdmin);
Expand Down Expand Up @@ -225,7 +225,7 @@ public async Task<IActionResult> ImportProfile([FromBody] ProfileOverviewImportR
// where treating unknown as false stripped admin for the rest of the session (#656); and an
// impersonation session, which AdminController deliberately mints with IsAdmin = false and which
// would otherwise be re-elevated by resolving the impersonated user's own roles (#663).
bool? resolvedAdmin = roles.Resolved && this.User.FindFirst("impersonatedBy") is null
bool? resolvedAdmin = roles.Resolved && !this.IsImpersonating
? roles.IsAdmin
: null;
var newToken = this._jwtService.GenerateTokenWithReplacedProfile(this.User, this.ProfileNo, resolvedAdmin);
Expand Down
84 changes: 42 additions & 42 deletions Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions Applications/Pgan.PoracleWebNet.App/ClientApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"@angular/router": "^21.2.19",
"@ngx-translate/core": "^18.0.0",
"@ngx-translate/http-loader": "^18.0.0",
"@types/leaflet": "^1.9.21",
"@types/leaflet": "^1.9.22",
"@types/leaflet-draw": "^1.0.13",
"chokidar": "^5.0.0",
"leaflet": "^1.9.4",
Expand All @@ -40,8 +40,8 @@
"@angular-eslint/eslint-plugin-template": "^19.0.0",
"@angular-eslint/schematics": "^19.0.0",
"@angular-eslint/template-parser": "^19.0.0",
"@angular/build": "^21.2.19",
"@angular/cli": "^21.2.19",
"@angular/build": "^21.2.20",
"@angular/cli": "^21.2.20",
"@angular/compiler-cli": "^21.2.19",
"@jest/globals": "^30.4.1",
"@types/jest": "^30.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@
</button>
</div>
}
@if (auth.user()?.adminDisable) {
@if (auth.user()?.adminDisable && auth.isImpersonating()) {
<!-- An admin inspecting a blocked account is being told about that account, not their own, so the
wording changes and the support links (addressed to the account holder) are dropped. See #706. -->
<div class="disabled-banner admin-disabled">
<mat-icon>block</mat-icon>
<span [innerHTML]="'BANNER.DISABLED_ACCOUNT_INSPECTED' | translate"></span>
</div>
} @else if (auth.user()?.adminDisable) {
<div class="disabled-banner admin-disabled">
<mat-icon>block</mat-icon>
<span [innerHTML]="'BANNER.DISABLED_ACCOUNT' | translate"></span>
Expand Down
Loading
Loading