OIDC issuer: chat tier for accounts without Active membership #52
6 changed files with 155 additions and 50 deletions
|
|
@ -68,25 +68,29 @@ The authorize endpoint reads the cookie session and the same actor facts the `/A
|
|||
- No session: challenge to `/auth/sign-in`, which resumes the original request after sign-in. OpenIddict parks the
|
||||
full request (its request token, in the OpenIddict token table, expiring after ten minutes, spent when used) and
|
||||
the return path carries only a short `request_uri`, so `LocalReturnPath.MaxLength` stays 256.
|
||||
- Signed in, membership not `Active`: redirect to the client's `redirect_uri` with `error=access_denied` and
|
||||
`error_description=The account is not an active GameCult member.`
|
||||
- Signed in, `Active`, but `NormalizedBifrostIdentity` empty: `error=access_denied` with
|
||||
- Signed in, membership `Suspended`: redirect to the client's `redirect_uri` with `error=access_denied` and
|
||||
`error_description=The account is suspended.`
|
||||
- Signed in with no membership, `Authenticated` or `PendingApproval` (the chat tier): treated like an active member for
|
||||
sign-in to chat and nothing else. The token carries the same `openid profile` claims; the issuer writes no membership,
|
||||
role or vote, and every member or governance surface (`/App`, `/eve/governance`, `/api/sealed-motions`, the fork desk)
|
||||
still asks for `Active` itself, so neither the session nor the token opens them.
|
||||
- Signed in, with an account that can chat, but `NormalizedBifrostIdentity` empty: `error=access_denied` with
|
||||
`error_description=The account has no Bifrost identity.`
|
||||
- Signed in, `Active`, but the identity is a reserved localpart (`BifrostIdentityService.IsReserved`: the 13 Synapse
|
||||
- Signed in, with an account that can chat, but the identity is a reserved localpart (`BifrostIdentityService.IsReserved`: the 13 Synapse
|
||||
accounts and the system names, the same 28 as MAS `banned_usernames`): `error=access_denied` with
|
||||
`error_description=The account's Bifrost identity is reserved.` Creation refuses these names, but a row minted before
|
||||
that refusal may hold one and there is no rename path, so the issuer never vouches for a Mind's or a system's localpart. The refusal is at authorize only: a code or access token issued for such a row before this check deployed stays redeemable until it expires (minutes), and token and userinfo re-read membership, not the name. Whether any such row exists is not known from the code; one query of `UserAccounts` against the 28 names would say.
|
||||
- Signed in and `Active`: a code is issued.
|
||||
- Token endpoint: membership is read again; an account no longer `Active` gets `invalid_grant` and no tokens.
|
||||
- Otherwise: a code is issued.
|
||||
- Token endpoint and userinfo: suspension is read again; an account suspended or deleted since gets `invalid_grant` (token) or 403 (userinfo). The logout reconciler ends the sessions of those same accounts; a chat-tier account is never logged out for lacking membership.
|
||||
|
||||
## Back-channel logout
|
||||
|
||||
When `BackchannelLogoutUri` is set, a hosted reconciler (`OidcLogoutReconciler`) runs every 30 seconds. It lists the
|
||||
subjects holding valid authorizations of the MAS client; for each subject whose membership is not `Active` (the same
|
||||
reading the token and userinfo endpoints use; a deleted account or a subject that is not an account id counts as not
|
||||
`Active`) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations,
|
||||
which also invalidates their tokens. Membership is never written by this path: any way of leaving `Active` (admin
|
||||
action, sync, a database edit, a future suspend) is covered because the work is derived from membership state.
|
||||
subjects holding valid authorizations of the MAS client; for each subject who may no longer chat (the same
|
||||
reading the token and userinfo endpoints use: membership `Suspended`, a deleted account, or a subject that is not an account id; an
|
||||
account with no membership or a pending one may chat and is left alone) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations,
|
||||
which also invalidates their tokens. Membership is never written by this path: any way of being suspended or deleted (admin
|
||||
action, sync, a database edit) is covered because the work is derived from membership state.
|
||||
|
||||
The token is an ES256 JWT signed with the issuer key, header `typ` `logout+jwt`, with `iss` as the id_token carries it,
|
||||
`aud` the MAS client id, `sub` the account id, `iat`, `exp` two minutes later, a fresh `jti` and the
|
||||
|
|
@ -142,8 +146,6 @@ Checked against the Window B list (2026-11-11). Present and tested: discovery, J
|
|||
the one client, claims to a Matrix-legal localpart (`[a-z0-9._-]`, 3-80, lower case), reserved names at creation and now at
|
||||
issuance, back-channel logout. Not present:
|
||||
|
||||
- **Non-member sign-in.** The gate is `Active` membership, so an account with no membership gets `access_denied`. A "Users"
|
||||
tier for chat registration, with no vote, is a policy choice not yet made; nothing here writes membership or votes.
|
||||
- **MAS upstream block.** `gamecult-ops` `chat/matrix-auth/config.yaml` has no `upstream_oauth2` (provider ULID,
|
||||
`on_conflict: fail`, `on_backchannel_logout: logout_all`, the redirect URI above); that lives in the ops repo.
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ namespace Bifrost.Web.Features.Oidc;
|
|||
/// </summary>
|
||||
public static class OidcEndpoints
|
||||
{
|
||||
public const string NotActiveDescription = "The account is not an active GameCult member.";
|
||||
public const string SuspendedDescription = "The account is suspended.";
|
||||
public const string NoIdentityDescription = "The account has no Bifrost identity.";
|
||||
public const string ReservedIdentityDescription = "The account's Bifrost identity is reserved.";
|
||||
|
||||
|
|
@ -49,9 +49,16 @@ public static class OidcEndpoints
|
|||
}
|
||||
|
||||
var actor = await actors.GetAsync(http.RequestAborted);
|
||||
if (actor.UserAccount is null || !actor.IsActiveMember)
|
||||
// Chat tier: any account that is not suspended may sign in to chat. Membership is neither required nor written;
|
||||
// every member or governance surface keeps asking for Active itself (the Active-member policy, IsActiveMember, ActiveMemberIdAsync).
|
||||
if (actor.UserAccount is null)
|
||||
{
|
||||
return Deny(Errors.AccessDenied, NotActiveDescription);
|
||||
return Deny(Errors.AccessDenied, NoIdentityDescription);
|
||||
}
|
||||
|
||||
if (actor.Membership?.Status == MembershipStatus.Suspended)
|
||||
{
|
||||
return Deny(Errors.AccessDenied, SuspendedDescription);
|
||||
}
|
||||
|
||||
var account = actor.UserAccount;
|
||||
|
|
@ -90,13 +97,13 @@ public static class OidcEndpoints
|
|||
private static async Task<IResult> ExchangeAsync(HttpContext http, BifrostDbContext db)
|
||||
{
|
||||
// Only the authorization code grant is enabled, so OpenIddict has refused every other grant already.
|
||||
// The code's own principal carries the claims chosen at authorize time; membership is read again here,
|
||||
// because the code outlives the authorize decision (a member suspended since gets no tokens).
|
||||
// The code's own principal carries the claims chosen at authorize time; suspension is read again here,
|
||||
// because the code outlives the authorize decision (an account suspended since gets no tokens).
|
||||
var result = await http.AuthenticateAsync(OpenIddictScheme);
|
||||
var subject = result.Principal?.GetClaim(Claims.Subject);
|
||||
if (!await IsStillActiveMemberAsync(db, subject, http.RequestAborted))
|
||||
if (!await IsStillChatEligibleAsync(db, subject, http.RequestAborted))
|
||||
{
|
||||
return Deny(Errors.InvalidGrant, NotActiveDescription);
|
||||
return Deny(Errors.InvalidGrant, SuspendedDescription);
|
||||
}
|
||||
|
||||
return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme);
|
||||
|
|
@ -110,10 +117,10 @@ public static class OidcEndpoints
|
|||
return Results.Challenge(authenticationSchemes: [OpenIddictScheme]);
|
||||
}
|
||||
|
||||
// An access token outlives the authorize decision too: a member suspended since is no longer answered.
|
||||
if (!await IsStillActiveMemberAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted))
|
||||
// An access token outlives the authorize decision too: an account suspended since is no longer answered.
|
||||
if (!await IsStillChatEligibleAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted))
|
||||
{
|
||||
return Deny(Errors.InsufficientAccess, NotActiveDescription);
|
||||
return Deny(Errors.InsufficientAccess, SuspendedDescription);
|
||||
}
|
||||
|
||||
var claims = new Dictionary<string, object?> { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) };
|
||||
|
|
@ -126,11 +133,24 @@ public static class OidcEndpoints
|
|||
return Results.Json(claims);
|
||||
}
|
||||
|
||||
/// <summary>The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints, the logout reconciler and the fork desk.</summary>
|
||||
internal static async Task<bool> IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) =>
|
||||
await ActiveMemberIdAsync(db, subject, cancellationToken) is not null;
|
||||
/// <summary>
|
||||
/// The one reading of "this subject may still chat", shared by the token and userinfo endpoints and the logout reconciler:
|
||||
/// the account exists and its membership is not Suspended. Membership may be absent, pending or Active. Re-read on every call.
|
||||
/// </summary>
|
||||
internal static async Task<bool> IsStillChatEligibleAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Guid.TryParse(subject, out var accountId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>The account id of the subject when it is an Active member now, else null. Re-read on every call.</summary>
|
||||
var account = await db.UserAccounts.AsNoTracking()
|
||||
.Include(x => x.Membership)
|
||||
.SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken);
|
||||
return account is not null && account.Membership?.Status != MembershipStatus.Suspended;
|
||||
}
|
||||
|
||||
/// <summary>The account id of the subject when it is an Active member now, else null. The door to the fork desk (cookie surfaces check IsActiveMember); a chat-tier token does not pass it. Re-read on every call.</summary>
|
||||
internal static async Task<Guid?> ActiveMemberIdAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Guid.TryParse(subject, out var accountId))
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ using static OpenIddict.Abstractions.OpenIddictConstants;
|
|||
namespace Bifrost.Web.Features.Oidc;
|
||||
|
||||
/// <summary>
|
||||
/// Ends the chat sessions of a subject who is no longer an Active member. Each tick it lists the subjects holding valid
|
||||
/// authorizations of the configured client; for each one the shared active-member reading refuses, it POSTs one signed
|
||||
/// Ends the chat sessions of a subject who is no longer allowed to chat (suspended, or the account gone). Each tick it lists the subjects holding valid
|
||||
/// authorizations of the configured client; for each one the shared chat-eligibility reading refuses, it POSTs one signed
|
||||
/// OIDC back-channel logout token to MAS and, only after a 2xx, revokes that subject's authorizations. The
|
||||
/// authorizations table is the queue: nothing is stored, so a restart, a MAS outage or a later-set URI resumes from it.
|
||||
/// Per-subject backoff lives in memory only. One subject's unexpected exception is logged and skipped, never aborting the
|
||||
|
|
@ -127,7 +127,7 @@ public sealed class OidcLogoutReconciler(
|
|||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
if (await OidcEndpoints.IsStillActiveMemberAsync(db, subject, cancellationToken))
|
||||
if (await OidcEndpoints.IsStillChatEligibleAsync(db, subject, cancellationToken))
|
||||
{
|
||||
_backoff.Remove(subject);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -107,20 +107,74 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
|
|||
Assert.Equal("Second Name", second.Claim("name"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MembershipStatus.Authenticated)]
|
||||
[InlineData(MembershipStatus.PendingApproval)]
|
||||
[InlineData(MembershipStatus.Suspended)]
|
||||
public async Task A_member_who_is_not_active_is_refused_with_access_denied_and_no_code(MembershipStatus status)
|
||||
[Fact]
|
||||
public async Task A_suspended_account_is_refused_with_access_denied_and_no_code()
|
||||
{
|
||||
var (client, _) = await SignedInAsync(status, "Not Active");
|
||||
var (client, _) = await SignedInAsync(MembershipStatus.Suspended, "Suspended One");
|
||||
var request = NewRequest();
|
||||
|
||||
var final = await DriveAsync(client, request);
|
||||
|
||||
var redirect = AssertRedirectToClient(final, request.State);
|
||||
Assert.Equal("access_denied", redirect["error"]);
|
||||
Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.NotActiveDescription, redirect["error_description"]);
|
||||
Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.SuspendedDescription, redirect["error_description"]);
|
||||
Assert.False(redirect.ContainsKey("code"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MembershipStatus.Authenticated)]
|
||||
[InlineData(MembershipStatus.PendingApproval)]
|
||||
public async Task A_non_member_gets_a_chat_only_token_and_writes_no_membership_or_vote(MembershipStatus status)
|
||||
{
|
||||
var (client, identity) = await SignedInAsync(status, "Chat Only");
|
||||
var accountId = _factory.AccountId(identity);
|
||||
var before = await _factory.RowCountsAsync();
|
||||
|
||||
var tokens = await CompleteFlowAsync(client);
|
||||
|
||||
var verified = await ValidateIdTokenAsync(tokens.IdToken, tokens.Request.Nonce);
|
||||
Assert.Equal(accountId.ToString("D"), verified.Claim("sub"));
|
||||
Assert.Equal(identity, verified.Claim("preferred_username"));
|
||||
|
||||
// The issuer wrote nothing about membership or votes, and the status is exactly what it was.
|
||||
Assert.Equal(before, await _factory.RowCountsAsync());
|
||||
Assert.Equal(status, await _factory.MembershipStatusAsync(identity));
|
||||
Assert.Equal(0, (await _factory.RowCountsAsync()).Votes);
|
||||
|
||||
// Neither the session that signed in to chat nor its token opens a member or governance surface.
|
||||
foreach (var path in new[] { "/eve/governance/surface", "/api/sealed-motions", "/App" })
|
||||
{
|
||||
using var viaSession = await client.GetAsync(path);
|
||||
Assert.NotEqual(HttpStatusCode.OK, viaSession.StatusCode);
|
||||
|
||||
using var viaToken = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
viaToken.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
|
||||
using var bearer = await NewClient().SendAsync(viaToken);
|
||||
Assert.NotEqual(HttpStatusCode.OK, bearer.StatusCode);
|
||||
}
|
||||
|
||||
using var desk = new HttpRequestMessage(HttpMethod.Get, "/api/desk/forks");
|
||||
desk.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
|
||||
using var deskResponse = await NewClient().SendAsync(desk);
|
||||
Assert.NotEqual(HttpStatusCode.OK, deskResponse.StatusCode);
|
||||
|
||||
// Last, because userinfo answers 500 on a .NET 10 runtime (the six known failures), and the rest must still run there.
|
||||
using var userinfo = await UserInfoAsync(tokens.AccessToken);
|
||||
Assert.Equal(HttpStatusCode.OK, userinfo.StatusCode);
|
||||
var info = JsonDocument.Parse(await userinfo.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(["name", "preferred_username", "sub"], info.EnumerateObject().Select(x => x.Name).Order().ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_chat_tier_account_with_a_reserved_identity_is_still_refused()
|
||||
{
|
||||
using var factory = new OidcFixedIdentityFactory("eureka", MembershipStatus.Authenticated);
|
||||
using var client = factory.CreateClient(ClientOptions());
|
||||
await RegisterAsync(client, $"cookie-{Guid.NewGuid():N}");
|
||||
var request = NewRequest();
|
||||
|
||||
var redirect = AssertRedirectToClient(await DriveAsync(client, request), request.State);
|
||||
Assert.Equal("access_denied", redirect["error"]);
|
||||
Assert.False(redirect.ContainsKey("code"));
|
||||
}
|
||||
|
||||
|
|
@ -619,11 +673,9 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
|
|||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MembershipStatus.Authenticated)]
|
||||
[InlineData(MembershipStatus.PendingApproval)]
|
||||
[InlineData(MembershipStatus.Suspended)]
|
||||
[InlineData(null)]
|
||||
public async Task A_member_no_longer_active_after_authorize_gets_no_tokens(MembershipStatus? status)
|
||||
public async Task An_account_suspended_or_gone_after_authorize_gets_no_tokens(MembershipStatus? status)
|
||||
{
|
||||
var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone");
|
||||
var request = NewRequest();
|
||||
|
|
@ -640,11 +692,9 @@ public sealed class OidcIssuerTests : IClassFixture<OidcWebApplicationFactory>
|
|||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MembershipStatus.Authenticated)]
|
||||
[InlineData(MembershipStatus.PendingApproval)]
|
||||
[InlineData(MembershipStatus.Suspended)]
|
||||
[InlineData(null)]
|
||||
public async Task Userinfo_refuses_an_access_token_whose_member_is_no_longer_active(MembershipStatus? status)
|
||||
public async Task Userinfo_refuses_an_access_token_whose_account_is_suspended_or_gone(MembershipStatus? status)
|
||||
{
|
||||
var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone");
|
||||
var tokens = await CompleteFlowAsync(client);
|
||||
|
|
|
|||
|
|
@ -37,9 +37,7 @@ public sealed class OidcLogoutTests
|
|||
|
||||
[Theory]
|
||||
[InlineData(MembershipStatus.Suspended)]
|
||||
[InlineData(MembershipStatus.PendingApproval)]
|
||||
[InlineData(MembershipStatus.Authenticated)]
|
||||
public async Task A_member_who_is_not_active_gets_one_signed_logout_and_loses_the_authorizations(MembershipStatus status)
|
||||
public async Task A_suspended_account_gets_one_signed_logout_and_loses_the_authorizations(MembershipStatus status)
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var (identity, idToken) = await factory.GrantAsync(status);
|
||||
|
|
@ -112,6 +110,23 @@ public sealed class OidcLogoutTests
|
|||
Assert.Equal(before, factory.ValidAuthorizations(activeSubject));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MembershipStatus.Authenticated)]
|
||||
[InlineData(MembershipStatus.PendingApproval)]
|
||||
public async Task A_chat_tier_non_member_is_never_sent_a_logout_and_keeps_the_authorizations(MembershipStatus status)
|
||||
{
|
||||
using var factory = new OidcLogoutFactory();
|
||||
var (chat, _) = await factory.GrantAsync(status);
|
||||
var subject = factory.AccountId(chat).ToString("D");
|
||||
var before = factory.ValidAuthorizations(subject);
|
||||
Assert.True(before > 0);
|
||||
|
||||
await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(factory.Mas.LogoutTokens());
|
||||
Assert.Equal(before, factory.ValidAuthorizations(subject));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_subject_that_cannot_be_delivered_does_not_block_another()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -179,6 +179,24 @@ public class OidcWebApplicationFactory : TestWebApplicationFactory
|
|||
}
|
||||
}
|
||||
|
||||
public async Task<MembershipStatus?> MembershipStatusAsync(string identity)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity);
|
||||
return (await db.Memberships.AsNoTracking().SingleOrDefaultAsync(x => x.UserAccountId == account.Id))?.Status;
|
||||
}
|
||||
|
||||
public sealed record RowCounts(int Accounts, int Memberships, int Votes, int RoleAssignments);
|
||||
|
||||
/// <summary>What the issuer must leave alone: accounts, memberships, role grants and votes.</summary>
|
||||
public async Task<RowCounts> RowCountsAsync()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
|
||||
return new RowCounts(await db.UserAccounts.CountAsync(), await db.Memberships.CountAsync(), await db.Votes.CountAsync(), await db.RoleAssignments.CountAsync());
|
||||
}
|
||||
|
||||
/// <summary>Sets the membership of the account registered under <paramref name="identity"/>.</summary>
|
||||
public async Task SetMembershipAsync(string identity, MembershipStatus status)
|
||||
{
|
||||
|
|
@ -244,15 +262,15 @@ public sealed class OidcNoIdentityFactory : OidcWebApplicationFactory
|
|||
/// reserved names were refused at creation (or any identity the test needs) reaches the authorize endpoint, since
|
||||
/// registration itself now refuses a reserved name.
|
||||
/// </summary>
|
||||
public sealed class OidcFixedIdentityFactory(string identity) : OidcWebApplicationFactory
|
||||
public sealed class OidcFixedIdentityFactory(string identity, MembershipStatus status = MembershipStatus.Active) : OidcWebApplicationFactory
|
||||
{
|
||||
protected override void ConfigureActors(IServiceCollection services)
|
||||
{
|
||||
services.RemoveAll<ICurrentBifrostActorAccessor>();
|
||||
services.AddScoped<ICurrentBifrostActorAccessor>(_ => new FixedIdentity(identity));
|
||||
services.AddScoped<ICurrentBifrostActorAccessor>(_ => new FixedIdentity(identity, status));
|
||||
}
|
||||
|
||||
private sealed class FixedIdentity(string identity) : ICurrentBifrostActorAccessor
|
||||
private sealed class FixedIdentity(string identity, MembershipStatus status) : ICurrentBifrostActorAccessor
|
||||
{
|
||||
public Task<CurrentBifrostActor> GetAsync(CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new CurrentBifrostActor(true, new UserAccount
|
||||
|
|
@ -261,7 +279,7 @@ public sealed class OidcFixedIdentityFactory(string identity) : OidcWebApplicati
|
|||
BifrostIdentity = identity,
|
||||
NormalizedBifrostIdentity = identity, // as given: the guard, not the fixture, must fold case
|
||||
DisplayName = "Fixed Identity",
|
||||
Membership = new Bifrost.Web.Domain.Membership { Status = MembershipStatus.Active }
|
||||
Membership = new Bifrost.Web.Domain.Membership { Status = status }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue