fix(mosaic): serialize credential lifecycle mutations
This commit is contained in:
@@ -548,10 +548,12 @@ get_gitea_token() {
|
|||||||
printf '%s\n' "$_resolved_token"
|
printf '%s\n' "$_resolved_token"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [[ -r "$_idtok" ]]; then
|
if [[ -e "$_idtok" || -L "$_idtok" ]]; then
|
||||||
|
local _resolved_token
|
||||||
|
_resolved_token=$(python3 "$script_dir/resolve-legacy-token.py" "$_idtok") || return 1
|
||||||
_resolution_path=identity
|
_resolution_path=identity
|
||||||
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
|
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
|
||||||
cat "$_idtok"
|
printf '%s\n' "$_resolved_token"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
|
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
|
||||||
|
|||||||
@@ -66,11 +66,12 @@ if [ -n "$ident" ]; then
|
|||||||
echo "password=${token}"
|
echo "password=${token}"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [ -r "$idtok" ]; then
|
if [ -e "$idtok" ] || [ -L "$idtok" ]; then
|
||||||
|
token=$(python3 "$script_dir/resolve-legacy-token.py" "$idtok") || exit 1
|
||||||
resolution_path=identity
|
resolution_path=identity
|
||||||
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
|
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
|
||||||
echo "username=${ident}"
|
echo "username=${ident}"
|
||||||
echo "password=$(cat "$idtok")"
|
echo "password=${token}"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then
|
if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fail-closed reader for one legacy per-seat token file."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
|
||||||
|
MAX_BYTES = 16 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def refuse(message: str) -> None:
|
||||||
|
print(f"legacy credential refused: {message}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
refuse("expected token path")
|
||||||
|
path = sys.argv[1]
|
||||||
|
parent = os.path.dirname(path)
|
||||||
|
try:
|
||||||
|
parent_stat = os.stat(parent, follow_symlinks=False)
|
||||||
|
except OSError:
|
||||||
|
refuse("credential directory unavailable")
|
||||||
|
if not stat.S_ISDIR(parent_stat.st_mode) or stat.S_ISLNK(parent_stat.st_mode):
|
||||||
|
refuse("credential directory is not a real directory")
|
||||||
|
if parent_stat.st_uid != os.getuid() or parent_stat.st_mode & 0o022:
|
||||||
|
refuse("credential directory owner or mode is unsafe")
|
||||||
|
try:
|
||||||
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
||||||
|
except OSError:
|
||||||
|
refuse("credential file unavailable or symbolic")
|
||||||
|
try:
|
||||||
|
file_stat = os.fstat(fd)
|
||||||
|
if not stat.S_ISREG(file_stat.st_mode):
|
||||||
|
refuse("credential is not a regular file")
|
||||||
|
if file_stat.st_uid != os.getuid() or file_stat.st_mode & 0o077:
|
||||||
|
refuse("credential owner or mode is unsafe")
|
||||||
|
content = os.read(fd, MAX_BYTES + 1)
|
||||||
|
if len(content) > MAX_BYTES:
|
||||||
|
refuse("credential exceeds size limit")
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
token = content.decode("utf-8").strip()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
refuse("credential is not UTF-8")
|
||||||
|
if not token or any(ch.isspace() for ch in token):
|
||||||
|
refuse("credential token is invalid")
|
||||||
|
sys.stdout.write(token + "\n")
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
||||||
@@ -35,6 +36,7 @@ mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
|
|||||||
|
|
||||||
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
||||||
cp "$SCRIPT_DIR/resolve-credential-envelope.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-credential-envelope.py"
|
cp "$SCRIPT_DIR/resolve-credential-envelope.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-credential-envelope.py"
|
||||||
|
cp "$SCRIPT_DIR/resolve-legacy-token.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-legacy-token.py"
|
||||||
chmod +x "$HELPER"
|
chmod +x "$HELPER"
|
||||||
|
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { timingSafeEqual } from 'node:crypto';
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
import { fstatSync, writeSync } from 'node:fs';
|
import { fstatSync, lstatSync, writeSync } from 'node:fs';
|
||||||
import { open, rename } from 'node:fs/promises';
|
import { open, rename } from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
CredentialJournalError,
|
CredentialJournalError,
|
||||||
listCredentialJournals,
|
listCredentialJournals,
|
||||||
} from '../credentials/audit-journal.js';
|
} from '../credentials/audit-journal.js';
|
||||||
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
|
||||||
import {
|
import {
|
||||||
runCredentialReadValidation,
|
runCredentialReadValidation,
|
||||||
runCredentialValidation,
|
runCredentialValidation,
|
||||||
@@ -542,10 +542,15 @@ export async function executeCredentialRotate(
|
|||||||
context.provider,
|
context.provider,
|
||||||
context.store,
|
context.store,
|
||||||
context.teaStore,
|
context.teaStore,
|
||||||
{ stateRoot: context.stateRoot, actor: options.actor, allowReplace: true },
|
{
|
||||||
|
stateRoot: context.stateRoot,
|
||||||
|
actor: options.actor,
|
||||||
|
allowReplace: true,
|
||||||
|
journal,
|
||||||
|
deferSuccessSeal: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (provisioned.outcome !== 'ok') {
|
if (provisioned.outcome !== 'ok') {
|
||||||
await journal.seal(provisioned.outcome, provisioned.reason.code);
|
|
||||||
old.secret.fill(0);
|
old.secret.fill(0);
|
||||||
return {
|
return {
|
||||||
...provisioned,
|
...provisioned,
|
||||||
@@ -616,7 +621,25 @@ export async function executeCredentialWire(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const locations = lifecycleLocations(options);
|
const locations = lifecycleLocations(options);
|
||||||
|
const seatEnvironmentRoot = join(defaultMosaicHome(options), 'fleet', 'agents');
|
||||||
|
if (dirname(options.seatEnv) !== seatEnvironmentRoot) {
|
||||||
|
return localLifecycleResult('wire', identity, options, {
|
||||||
|
outcome: 'refused',
|
||||||
|
code: 'insecure-credential-destination',
|
||||||
|
message: 'Seat environment must be directly beneath the governed fleet agent directory.',
|
||||||
|
});
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
|
ensureManagedDirectory(seatEnvironmentRoot, seatEnvironmentRoot);
|
||||||
|
const parent = lstatSync(seatEnvironmentRoot);
|
||||||
|
if (
|
||||||
|
!parent.isDirectory() ||
|
||||||
|
parent.isSymbolicLink() ||
|
||||||
|
parent.uid !== process.getuid?.() ||
|
||||||
|
(parent.mode & 0o022) !== 0
|
||||||
|
) {
|
||||||
|
throw new Error('seat environment directory is unsafe');
|
||||||
|
}
|
||||||
const context = await lifecycleContext(options);
|
const context = await lifecycleContext(options);
|
||||||
if (context.registry.resolve(options.estate, options.host) === undefined) {
|
if (context.registry.resolve(options.estate, options.host) === undefined) {
|
||||||
return localLifecycleResult('wire', identity, options, {
|
return localLifecycleResult('wire', identity, options, {
|
||||||
@@ -668,6 +691,7 @@ export async function executeCredentialWire(
|
|||||||
`MOSAIC_CREDENTIAL_ESTATE=${options.estate}`,
|
`MOSAIC_CREDENTIAL_ESTATE=${options.estate}`,
|
||||||
`GITEA_LOGIN=${identity}--${options.host}`,
|
`GITEA_LOGIN=${identity}--${options.host}`,
|
||||||
);
|
);
|
||||||
|
const parentBefore = lstatSync(seatEnvironmentRoot);
|
||||||
const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`;
|
const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`;
|
||||||
const handle = await open(temp, 'wx', 0o600);
|
const handle = await open(temp, 'wx', 0o600);
|
||||||
try {
|
try {
|
||||||
@@ -676,6 +700,15 @@ export async function executeCredentialWire(
|
|||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
await handle.close();
|
||||||
}
|
}
|
||||||
|
const parentAfter = lstatSync(seatEnvironmentRoot);
|
||||||
|
if (
|
||||||
|
parentAfter.dev !== parentBefore.dev ||
|
||||||
|
parentAfter.ino !== parentBefore.ino ||
|
||||||
|
parentAfter.uid !== process.getuid?.() ||
|
||||||
|
(parentAfter.mode & 0o022) !== 0
|
||||||
|
) {
|
||||||
|
throw new Error('seat environment directory changed during mutation');
|
||||||
|
}
|
||||||
await rename(temp, options.seatEnv);
|
await rename(temp, options.seatEnv);
|
||||||
await journal.recordMutation('wire-applied');
|
await journal.recordMutation('wire-applied');
|
||||||
await journal.seal('ok', 'wire-verified');
|
await journal.seal('ok', 'wire-verified');
|
||||||
@@ -813,6 +846,15 @@ async function executeAuthorizedInventoryRead(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const context = await lifecycleContext(options);
|
const context = await lifecycleContext(options);
|
||||||
|
if (context.registry.inventoryAuthority(options.estate) !== options.actor) {
|
||||||
|
await journal.seal('refused', 'permission-denied');
|
||||||
|
return localLifecycleResult(operation, 'all', options, {
|
||||||
|
outcome: 'refused',
|
||||||
|
code: 'permission-denied',
|
||||||
|
message: 'Explicit actor is not the configured estate inventory authority.',
|
||||||
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
|
});
|
||||||
|
}
|
||||||
const authority = await lifecycleAuthority(options.actor, options);
|
const authority = await lifecycleAuthority(options.actor, options);
|
||||||
const providerIdentity = await context.provider.readIdentity(authority);
|
const providerIdentity = await context.provider.readIdentity(authority);
|
||||||
if (providerIdentity.login !== options.actor) {
|
if (providerIdentity.login !== options.actor) {
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ export interface CredentialHostConfigDto {
|
|||||||
export interface CredentialEstateConfigDto {
|
export interface CredentialEstateConfigDto {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly readOnlyControlIdentity?: string;
|
readonly readOnlyControlIdentity?: string;
|
||||||
|
readonly inventoryAuthorityIdentity?: string;
|
||||||
readonly hosts: readonly CredentialHostConfigDto[];
|
readonly hosts: readonly CredentialHostConfigDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const estateSchema = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().regex(NAME),
|
name: z.string().regex(NAME),
|
||||||
readOnlyControlIdentity: z.string().regex(IDENTITY).optional(),
|
readOnlyControlIdentity: z.string().regex(IDENTITY).optional(),
|
||||||
|
inventoryAuthorityIdentity: z.string().regex(IDENTITY).optional(),
|
||||||
hosts: z.array(hostSchema).min(1),
|
hosts: z.array(hostSchema).min(1),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@@ -105,6 +106,17 @@ export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inventoryAuthority(estate: string): string {
|
||||||
|
const identity = this.estates.get(estate)?.inventoryAuthorityIdentity;
|
||||||
|
if (identity === undefined) {
|
||||||
|
throw new CredentialEstateRegistryError(
|
||||||
|
'inventory-authority-missing',
|
||||||
|
`estate ${estate} has no delegated inventory authority identity`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
readOnlyControl(estate: string): string {
|
readOnlyControl(estate: string): string {
|
||||||
const identity = this.estates.get(estate)?.readOnlyControlIdentity;
|
const identity = this.estates.get(estate)?.readOnlyControlIdentity;
|
||||||
if (identity === undefined) {
|
if (identity === undefined) {
|
||||||
|
|||||||
@@ -180,10 +180,17 @@ export class FileCredentialResolver implements CredentialResolver {
|
|||||||
});
|
});
|
||||||
const binding = bindingSchema.safeParse(JSON.parse(bindingSnapshot.content.toString('utf8')));
|
const binding = bindingSchema.safeParse(JSON.parse(bindingSnapshot.content.toString('utf8')));
|
||||||
const digest = createHash('sha256').update(snapshot.content).digest('hex');
|
const digest = createHash('sha256').update(snapshot.content).digest('hex');
|
||||||
if (!binding.success || binding.data.tokenDigest !== digest) {
|
if (
|
||||||
|
!binding.success ||
|
||||||
|
binding.data.tokenDigest !== digest ||
|
||||||
|
binding.data.identity !== identity ||
|
||||||
|
binding.data.estate !== estate ||
|
||||||
|
binding.data.host !== host ||
|
||||||
|
binding.data.providerLogin !== identity
|
||||||
|
) {
|
||||||
throw new CredentialStoreError(
|
throw new CredentialStoreError(
|
||||||
'credential-generation-mismatch',
|
'credential-generation-mismatch',
|
||||||
'token and binding metadata are not one committed generation',
|
'token and binding metadata are not one committed identity-bound generation',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -410,8 +417,33 @@ export class FileCredentialStore {
|
|||||||
.sort();
|
.sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(identity: string, estate: string, host: string): Promise<void> {
|
async remove(
|
||||||
|
identity: string,
|
||||||
|
estate: string,
|
||||||
|
host: string,
|
||||||
|
expectedTokenDigest?: string,
|
||||||
|
): Promise<void> {
|
||||||
const paths = this.paths(identity, estate, host);
|
const paths = this.paths(identity, estate, host);
|
||||||
|
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
|
||||||
|
let lock;
|
||||||
|
try {
|
||||||
|
lock = await open(lockPath, 'wx', 0o600);
|
||||||
|
} catch {
|
||||||
|
throw new CredentialStoreError(
|
||||||
|
'conflicting-credential-mutation',
|
||||||
|
'another mutation owns the identity lock',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (expectedTokenDigest !== undefined) {
|
||||||
|
const current = await this.readBinding(identity, estate, host);
|
||||||
|
if (current === undefined || current.tokenDigest !== expectedTokenDigest) {
|
||||||
|
throw new CredentialStoreError(
|
||||||
|
'credential-generation-mismatch',
|
||||||
|
'credential generation changed before removal',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
await unlink(paths.token).catch((error: unknown): void => {
|
await unlink(paths.token).catch((error: unknown): void => {
|
||||||
if (!isMissingFile(error)) throw error;
|
if (!isMissingFile(error)) throw error;
|
||||||
});
|
});
|
||||||
@@ -422,5 +454,10 @@ export class FileCredentialStore {
|
|||||||
if (!isMissingFile(error)) throw error;
|
if (!isMissingFile(error)) throw error;
|
||||||
});
|
});
|
||||||
await syncDirectory(this.tokenDirectory);
|
await syncDirectory(this.tokenDirectory);
|
||||||
|
} finally {
|
||||||
|
await lock.close();
|
||||||
|
await unlink(lockPath).catch((): void => undefined);
|
||||||
|
await syncDirectory(this.tokenDirectory);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export interface LifecycleOptions {
|
|||||||
readonly actor: string;
|
readonly actor: string;
|
||||||
readonly now?: () => string;
|
readonly now?: () => string;
|
||||||
readonly allowReplace?: boolean;
|
readonly allowReplace?: boolean;
|
||||||
|
readonly journal?: CredentialAuditJournal;
|
||||||
|
readonly deferSuccessSeal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lifecycleResult(
|
function lifecycleResult(
|
||||||
@@ -113,7 +115,7 @@ export async function provisionCredential(
|
|||||||
teaStore: TeaLoginStore,
|
teaStore: TeaLoginStore,
|
||||||
options: LifecycleOptions,
|
options: LifecycleOptions,
|
||||||
): Promise<CredentialLifecycleResultDto> {
|
): Promise<CredentialLifecycleResultDto> {
|
||||||
const journal = await openLifecycleJournal('provision', request, options);
|
const journal = options.journal ?? (await openLifecycleJournal('provision', request, options));
|
||||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||||
let minted: MintedToken | undefined;
|
let minted: MintedToken | undefined;
|
||||||
let failureCode = 'mutation-state-unknown';
|
let failureCode = 'mutation-state-unknown';
|
||||||
@@ -192,14 +194,19 @@ export async function provisionCredential(
|
|||||||
contentType: readBack.contentType,
|
contentType: readBack.contentType,
|
||||||
decision: 'scope-verified',
|
decision: 'scope-verified',
|
||||||
});
|
});
|
||||||
|
if (options.deferSuccessSeal !== true) {
|
||||||
await journal.seal('ok', 'provision-verified');
|
await journal.seal('ok', 'provision-verified');
|
||||||
|
}
|
||||||
return lifecycleResult('provision', request, {
|
return lifecycleResult('provision', request, {
|
||||||
outcome: 'ok',
|
outcome: 'ok',
|
||||||
mutation: 'applied',
|
mutation: 'applied',
|
||||||
code: 'provision-verified',
|
code: options.deferSuccessSeal === true ? 'replacement-staged' : 'provision-verified',
|
||||||
message: 'Provider principal and exact token scopes were read back and stored.',
|
message:
|
||||||
|
options.deferSuccessSeal === true
|
||||||
|
? 'Replacement was read back and staged under the open rotation transaction.'
|
||||||
|
: 'Provider principal and exact token scopes were read back and stored.',
|
||||||
journalId: journal.journalId(),
|
journalId: journal.journalId(),
|
||||||
auditState: 'sealed',
|
auditState: options.deferSuccessSeal === true ? 'open' : 'sealed',
|
||||||
providerIdentity: identity.login,
|
providerIdentity: identity.login,
|
||||||
token: readBack,
|
token: readBack,
|
||||||
teaLogin: { ...teaLogin, state: 'registered' },
|
teaLogin: { ...teaLogin, state: 'registered' },
|
||||||
@@ -305,7 +312,7 @@ export async function revokeCredential(
|
|||||||
throw new Error('Tea login still exists after revocation');
|
throw new Error('Tea login still exists after revocation');
|
||||||
}
|
}
|
||||||
await journal.recordMutation('tea-login-removed');
|
await journal.recordMutation('tea-login-removed');
|
||||||
await store.remove(request.identity, request.estate, request.host);
|
await store.remove(request.identity, request.estate, request.host, binding.tokenDigest);
|
||||||
await journal.seal('ok', 'revoke-verified');
|
await journal.seal('ok', 'revoke-verified');
|
||||||
return lifecycleResult('revoke', request, {
|
return lifecycleResult('revoke', request, {
|
||||||
outcome: 'ok',
|
outcome: 'ok',
|
||||||
|
|||||||
Reference in New Issue
Block a user