mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
610 lines
18 KiB
JavaScript
610 lines
18 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const { chmod, mkdtemp, rename, rm, writeFile } = require('node:fs/promises');
|
|
const { tmpdir } = require('node:os');
|
|
const { join } = require('node:path');
|
|
const { generateKeyPairSync, sign } = require('node:crypto');
|
|
const { test } = require('node:test');
|
|
|
|
const {
|
|
ClusterPluginPackageIdentityKeysetUnavailableError,
|
|
createClusterPluginPackageIdentityKeysetFile,
|
|
createClusterWorkerCredentialIdentityKeysetFile,
|
|
createClusterAutomationIdentityKeysetFile,
|
|
createClusterApprovalIdentityKeysetFile,
|
|
createClusterModelProviderCredentialIdentityKeysetFile,
|
|
createClusterRunIdentityKeysetFile,
|
|
} = require('@qinglong/cluster-admin/plugin-package-identity-keyset');
|
|
|
|
const NOW_MS = 1_700_000_000_000;
|
|
const ISSUER = 'https://identity.example.test/';
|
|
const AUDIENCE = 'qinglong3-package-management';
|
|
|
|
function reviewedKey(kid) {
|
|
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
return {
|
|
kid,
|
|
privateKey,
|
|
publicJwk: {
|
|
...publicKey.export({ format: 'jwk' }),
|
|
alg: 'EdDSA',
|
|
kid,
|
|
use: 'sig',
|
|
},
|
|
};
|
|
}
|
|
|
|
function keyset(generation, keys, revokedKids = []) {
|
|
return {
|
|
schemaVersion: 1,
|
|
generation,
|
|
issuer: ISSUER,
|
|
audience: AUDIENCE,
|
|
keys: keys.map((key) => key.publicJwk),
|
|
revokedKids,
|
|
assuranceMappings: [
|
|
{
|
|
acr: 'urn:ql3:mfa',
|
|
assurance: 'multi_factor',
|
|
requiredAmr: ['pwd', 'otp'],
|
|
},
|
|
],
|
|
constraints: {
|
|
maxAssertionBytes: 8 * 1024,
|
|
maxLifetimeMs: 5 * 60 * 1000,
|
|
maxAuthenticationAgeMs: 5 * 60 * 1000,
|
|
clockSkewMs: 5 * 1000,
|
|
},
|
|
};
|
|
}
|
|
|
|
function assertion(key, overrides = {}) {
|
|
const header = Buffer.from(
|
|
JSON.stringify({
|
|
alg: 'EdDSA',
|
|
kid: key.kid,
|
|
typ: 'ql3-plugin-package-management+jwt',
|
|
}),
|
|
).toString('base64url');
|
|
const now = Math.floor(NOW_MS / 1000);
|
|
const payload = Buffer.from(
|
|
JSON.stringify({
|
|
acr: 'urn:ql3:mfa',
|
|
amr: ['pwd', 'otp'],
|
|
aud: AUDIENCE,
|
|
auth_time: now - 10,
|
|
exp: now + 120,
|
|
iat: now,
|
|
iss: ISSUER,
|
|
jti: `assertion-${key.kid}`,
|
|
ql3_purpose: 'plugin-package-management',
|
|
sub: 'user-1',
|
|
...overrides,
|
|
}),
|
|
).toString('base64url');
|
|
const signed = `${header}.${payload}`;
|
|
return `${signed}.${sign(
|
|
null,
|
|
Buffer.from(signed, 'ascii'),
|
|
key.privateKey,
|
|
).toString('base64url')}`;
|
|
}
|
|
|
|
function workerAssertion(key, overrides = {}) {
|
|
const header = Buffer.from(
|
|
JSON.stringify({
|
|
alg: 'EdDSA',
|
|
kid: key.kid,
|
|
typ: 'ql3-worker-credential-management+jwt',
|
|
}),
|
|
).toString('base64url');
|
|
const now = Math.floor(NOW_MS / 1000);
|
|
const payload = Buffer.from(
|
|
JSON.stringify({
|
|
acr: 'urn:ql3:mfa',
|
|
amr: ['pwd', 'otp'],
|
|
aud: 'qinglong3-worker-credential-management',
|
|
auth_time: now - 10,
|
|
exp: now + 120,
|
|
iat: now,
|
|
iss: ISSUER,
|
|
jti: `worker-assertion-${key.kid}`,
|
|
ql3_purpose: 'worker-credential-management',
|
|
sub: 'worker-operator-1',
|
|
...overrides,
|
|
}),
|
|
).toString('base64url');
|
|
const signed = `${header}.${payload}`;
|
|
return `${signed}.${sign(
|
|
null,
|
|
Buffer.from(signed, 'ascii'),
|
|
key.privateKey,
|
|
).toString('base64url')}`;
|
|
}
|
|
|
|
function automationAssertion(key, overrides = {}) {
|
|
const header = Buffer.from(
|
|
JSON.stringify({
|
|
alg: 'EdDSA',
|
|
kid: key.kid,
|
|
typ: 'ql3-automation-management+jwt',
|
|
}),
|
|
).toString('base64url');
|
|
const now = Math.floor(NOW_MS / 1000);
|
|
const payload = Buffer.from(
|
|
JSON.stringify({
|
|
acr: 'urn:ql3:mfa',
|
|
amr: ['pwd', 'otp'],
|
|
aud: 'qinglong3-automation-management',
|
|
auth_time: now - 10,
|
|
exp: now + 120,
|
|
iat: now,
|
|
iss: ISSUER,
|
|
jti: `automation-assertion-${key.kid}`,
|
|
ql3_purpose: 'automation-management',
|
|
sub: 'automation-operator-1',
|
|
...overrides,
|
|
}),
|
|
).toString('base64url');
|
|
const signed = `${header}.${payload}`;
|
|
return `${signed}.${sign(
|
|
null,
|
|
Buffer.from(signed, 'ascii'),
|
|
key.privateKey,
|
|
).toString('base64url')}`;
|
|
}
|
|
|
|
function approvalAssertion(key, overrides = {}) {
|
|
const header = Buffer.from(
|
|
JSON.stringify({
|
|
alg: 'EdDSA',
|
|
kid: key.kid,
|
|
typ: 'ql3-approval-management+jwt',
|
|
}),
|
|
).toString('base64url');
|
|
const now = Math.floor(NOW_MS / 1000);
|
|
const payload = Buffer.from(
|
|
JSON.stringify({
|
|
acr: 'urn:ql3:mfa',
|
|
amr: ['pwd', 'otp'],
|
|
aud: 'qinglong3-approval-management',
|
|
auth_time: now - 10,
|
|
exp: now + 120,
|
|
iat: now,
|
|
iss: ISSUER,
|
|
jti: `approval-assertion-${key.kid}`,
|
|
ql3_purpose: 'approval-management',
|
|
sub: 'approval-owner-1',
|
|
...overrides,
|
|
}),
|
|
).toString('base64url');
|
|
const signed = `${header}.${payload}`;
|
|
return `${signed}.${sign(
|
|
null,
|
|
Buffer.from(signed, 'ascii'),
|
|
key.privateKey,
|
|
).toString('base64url')}`;
|
|
}
|
|
|
|
function providerCredentialAssertion(key, overrides = {}) {
|
|
const header = Buffer.from(
|
|
JSON.stringify({
|
|
alg: 'EdDSA',
|
|
kid: key.kid,
|
|
typ: 'ql3-model-provider-credential-management+jwt',
|
|
}),
|
|
).toString('base64url');
|
|
const now = Math.floor(NOW_MS / 1000);
|
|
const payload = Buffer.from(
|
|
JSON.stringify({
|
|
acr: 'urn:ql3:mfa',
|
|
amr: ['pwd', 'otp'],
|
|
aud: 'qinglong3-model-provider-credential-management',
|
|
auth_time: now - 10,
|
|
exp: now + 120,
|
|
iat: now,
|
|
iss: ISSUER,
|
|
jti: `provider-credential-assertion-${key.kid}`,
|
|
ql3_purpose: 'model-provider-credential-management',
|
|
sub: 'provider-credential-operator-1',
|
|
...overrides,
|
|
}),
|
|
).toString('base64url');
|
|
const signed = `${header}.${payload}`;
|
|
return `${signed}.${sign(
|
|
null,
|
|
Buffer.from(signed, 'ascii'),
|
|
key.privateKey,
|
|
).toString('base64url')}`;
|
|
}
|
|
|
|
function runAssertion(key, overrides = {}) {
|
|
const header = Buffer.from(
|
|
JSON.stringify({
|
|
alg: 'EdDSA',
|
|
kid: key.kid,
|
|
typ: 'ql3-run-management+jwt',
|
|
}),
|
|
).toString('base64url');
|
|
const now = Math.floor(NOW_MS / 1000);
|
|
const payload = Buffer.from(
|
|
JSON.stringify({
|
|
acr: 'urn:ql3:mfa',
|
|
amr: ['pwd', 'otp'],
|
|
aud: 'qinglong3-run-management',
|
|
auth_time: now - 10,
|
|
exp: now + 120,
|
|
iat: now,
|
|
iss: ISSUER,
|
|
jti: `run-assertion-${key.kid}`,
|
|
ql3_purpose: 'run-management',
|
|
sub: 'run-operator-1',
|
|
...overrides,
|
|
}),
|
|
).toString('base64url');
|
|
const signed = `${header}.${payload}`;
|
|
return `${signed}.${sign(
|
|
null,
|
|
Buffer.from(signed, 'ascii'),
|
|
key.privateKey,
|
|
).toString('base64url')}`;
|
|
}
|
|
|
|
async function atomicWrite(filePath, document) {
|
|
const nextPath = `${filePath}.next`;
|
|
await writeFile(nextPath, `${JSON.stringify(document)}\n`, { mode: 0o644 });
|
|
await rename(nextPath, filePath);
|
|
}
|
|
|
|
async function fixture(run) {
|
|
const directory = await mkdtemp(join(tmpdir(), 'ql3-identity-keyset-'));
|
|
const filePath = join(directory, 'keyset.json');
|
|
try {
|
|
return await run({ directory, filePath });
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test('loads one bounded keyset and authenticates through the current generation', async () => {
|
|
await fixture(async ({ filePath }) => {
|
|
const first = reviewedKey('issuer-key-1');
|
|
await atomicWrite(filePath, keyset(1, [first]));
|
|
const provider = createClusterPluginPackageIdentityKeysetFile({
|
|
filePath,
|
|
now: () => NOW_MS,
|
|
});
|
|
|
|
assert.deepEqual(await provider.reload(), {
|
|
schemaVersion: 1,
|
|
generation: 1,
|
|
digest: (await provider.reload()).digest,
|
|
issuer: ISSUER,
|
|
audience: AUDIENCE,
|
|
activeKeyIds: ['issuer-key-1'],
|
|
revokedKeyIds: [],
|
|
});
|
|
const principal = await provider.bind(assertion(first)).authenticate();
|
|
assert.deepEqual(principal.subject, { type: 'user', id: 'user-1' });
|
|
assert.equal(principal.assurance, 'multi_factor');
|
|
});
|
|
});
|
|
|
|
test('loads a Worker credential keyset with a distinct assertion purpose', async () => {
|
|
await fixture(async ({ filePath }) => {
|
|
const key = reviewedKey('worker-identity-key-1');
|
|
await atomicWrite(filePath, {
|
|
...keyset(1, [key]),
|
|
audience: 'qinglong3-worker-credential-management',
|
|
});
|
|
const provider = createClusterWorkerCredentialIdentityKeysetFile({
|
|
filePath,
|
|
now: () => NOW_MS,
|
|
});
|
|
|
|
const principal = await provider.bind(workerAssertion(key)).authenticate();
|
|
assert.deepEqual(principal.subject, {
|
|
type: 'user',
|
|
id: 'worker-operator-1',
|
|
});
|
|
await assert.rejects(
|
|
provider
|
|
.bind(
|
|
assertion(key, {
|
|
aud: 'qinglong3-worker-credential-management',
|
|
}),
|
|
)
|
|
.authenticate(),
|
|
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
|
|
);
|
|
});
|
|
});
|
|
|
|
test('loads an automation keyset with a purpose isolated from other management planes', async () => {
|
|
await fixture(async ({ filePath }) => {
|
|
const key = reviewedKey('automation-identity-key-1');
|
|
await atomicWrite(filePath, {
|
|
...keyset(1, [key]),
|
|
audience: 'qinglong3-automation-management',
|
|
});
|
|
const provider = createClusterAutomationIdentityKeysetFile({
|
|
filePath,
|
|
now: () => NOW_MS,
|
|
});
|
|
const principal = await provider
|
|
.bind(automationAssertion(key))
|
|
.authenticate();
|
|
assert.deepEqual(principal.subject, {
|
|
type: 'user',
|
|
id: 'automation-operator-1',
|
|
});
|
|
await assert.rejects(
|
|
provider.bind(workerAssertion(key)).authenticate(),
|
|
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
|
|
);
|
|
await assert.rejects(provider.bind(assertion(key)).authenticate(), {
|
|
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
|
});
|
|
});
|
|
});
|
|
|
|
test('loads an Approval keyset isolated by type, purpose and audience', async () => {
|
|
await fixture(async ({ filePath }) => {
|
|
const key = reviewedKey('approval-identity-key-1');
|
|
await atomicWrite(filePath, {
|
|
...keyset(1, [key]),
|
|
audience: 'qinglong3-approval-management',
|
|
});
|
|
const provider = createClusterApprovalIdentityKeysetFile({
|
|
filePath,
|
|
now: () => NOW_MS,
|
|
});
|
|
const principal = await provider.bind(approvalAssertion(key)).authenticate();
|
|
assert.deepEqual(principal.subject, {
|
|
type: 'user',
|
|
id: 'approval-owner-1',
|
|
});
|
|
await assert.rejects(provider.bind(automationAssertion(key)).authenticate(), {
|
|
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
|
});
|
|
await assert.rejects(provider.bind(assertion(key)).authenticate(), {
|
|
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
|
});
|
|
});
|
|
});
|
|
|
|
test('loads a provider credential keyset isolated by type, purpose and audience', async () => {
|
|
await fixture(async ({ filePath }) => {
|
|
const key = reviewedKey('provider-credential-identity-key-1');
|
|
await atomicWrite(filePath, {
|
|
...keyset(1, [key]),
|
|
audience: 'qinglong3-model-provider-credential-management',
|
|
});
|
|
const provider = createClusterModelProviderCredentialIdentityKeysetFile({
|
|
filePath,
|
|
now: () => NOW_MS,
|
|
});
|
|
const principal = await provider
|
|
.bind(providerCredentialAssertion(key))
|
|
.authenticate();
|
|
assert.deepEqual(principal.subject, {
|
|
type: 'user',
|
|
id: 'provider-credential-operator-1',
|
|
});
|
|
await assert.rejects(
|
|
provider.bind(automationAssertion(key)).authenticate(),
|
|
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
|
|
);
|
|
await assert.rejects(
|
|
provider
|
|
.bind(
|
|
providerCredentialAssertion(key, {
|
|
ql3_purpose: 'automation-management',
|
|
}),
|
|
)
|
|
.authenticate(),
|
|
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
|
|
);
|
|
});
|
|
});
|
|
|
|
test('loads a Run keyset isolated from every other management purpose', async () => {
|
|
await fixture(async ({ filePath }) => {
|
|
const key = reviewedKey('run-identity-key-1');
|
|
await atomicWrite(filePath, {
|
|
...keyset(1, [key]),
|
|
audience: 'qinglong3-run-management',
|
|
});
|
|
const provider = createClusterRunIdentityKeysetFile({
|
|
filePath,
|
|
now: () => NOW_MS,
|
|
});
|
|
const principal = await provider.bind(runAssertion(key)).authenticate();
|
|
assert.deepEqual(principal.subject, {
|
|
type: 'user',
|
|
id: 'run-operator-1',
|
|
});
|
|
await assert.rejects(provider.bind(approvalAssertion(key)).authenticate(), {
|
|
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
|
});
|
|
await assert.rejects(
|
|
provider
|
|
.bind(runAssertion(key, { ql3_purpose: 'approval-management' }))
|
|
.authenticate(),
|
|
{ code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID' },
|
|
);
|
|
});
|
|
});
|
|
|
|
test('supports overlap rotation then immediately revokes the previous key', async () => {
|
|
await fixture(async ({ filePath }) => {
|
|
const first = reviewedKey('issuer-key-1');
|
|
const second = reviewedKey('issuer-key-2');
|
|
await atomicWrite(filePath, keyset(1, [first]));
|
|
const provider = createClusterPluginPackageIdentityKeysetFile({
|
|
filePath,
|
|
now: () => NOW_MS,
|
|
});
|
|
await provider.reload();
|
|
|
|
await atomicWrite(filePath, keyset(2, [first, second]));
|
|
assert.deepEqual((await provider.reload()).activeKeyIds, [
|
|
'issuer-key-1',
|
|
'issuer-key-2',
|
|
]);
|
|
assert.equal(
|
|
(await provider.bind(assertion(second)).authenticate()).subject.id,
|
|
'user-1',
|
|
);
|
|
|
|
await atomicWrite(filePath, keyset(3, [first, second], ['issuer-key-1']));
|
|
assert.deepEqual(await provider.reload(), {
|
|
schemaVersion: 1,
|
|
generation: 3,
|
|
digest: (await provider.reload()).digest,
|
|
issuer: ISSUER,
|
|
audience: AUDIENCE,
|
|
activeKeyIds: ['issuer-key-2'],
|
|
revokedKeyIds: ['issuer-key-1'],
|
|
});
|
|
await assert.rejects(provider.bind(assertion(first)).authenticate(), {
|
|
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_ASSERTION_INVALID',
|
|
});
|
|
assert.equal(
|
|
(await provider.bind(assertion(second)).authenticate()).subject.id,
|
|
'user-1',
|
|
);
|
|
});
|
|
});
|
|
|
|
test('rejects generation rollback, same-generation rewrite and implicit removal', async () => {
|
|
await fixture(async ({ filePath }) => {
|
|
const first = reviewedKey('issuer-key-1');
|
|
const second = reviewedKey('issuer-key-2');
|
|
await atomicWrite(filePath, keyset(1, [first]));
|
|
const provider = createClusterPluginPackageIdentityKeysetFile({
|
|
filePath,
|
|
now: () => NOW_MS,
|
|
});
|
|
await provider.reload();
|
|
|
|
await atomicWrite(filePath, keyset(1, [first, second]));
|
|
await assert.rejects(provider.reload(), {
|
|
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_UNAVAILABLE',
|
|
});
|
|
|
|
await atomicWrite(filePath, keyset(2, [first, second]));
|
|
await provider.reload();
|
|
await atomicWrite(filePath, keyset(3, [second]));
|
|
await assert.rejects(provider.reload(), {
|
|
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_UNAVAILABLE',
|
|
});
|
|
|
|
await atomicWrite(filePath, keyset(1, [first]));
|
|
await assert.rejects(provider.reload(), {
|
|
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_UNAVAILABLE',
|
|
});
|
|
});
|
|
});
|
|
|
|
test('keeps revocation append-only and rejects stale fallback on file failure', async () => {
|
|
await fixture(async ({ filePath }) => {
|
|
const first = reviewedKey('issuer-key-1');
|
|
const second = reviewedKey('issuer-key-2');
|
|
await atomicWrite(filePath, keyset(1, [first, second]));
|
|
const provider = createClusterPluginPackageIdentityKeysetFile({
|
|
filePath,
|
|
now: () => NOW_MS,
|
|
});
|
|
await provider.reload();
|
|
await atomicWrite(filePath, keyset(2, [first, second], ['issuer-key-1']));
|
|
await provider.reload();
|
|
|
|
await atomicWrite(filePath, keyset(3, [first, second]));
|
|
await assert.rejects(provider.reload(), {
|
|
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_UNAVAILABLE',
|
|
});
|
|
|
|
await atomicWrite(filePath, keyset(3, [first, second], ['issuer-key-1']));
|
|
await chmod(filePath, 0o666);
|
|
await assert.rejects(
|
|
provider.bind(assertion(second)).authenticate(),
|
|
ClusterPluginPackageIdentityKeysetUnavailableError,
|
|
);
|
|
});
|
|
});
|
|
|
|
test('rechecks one durable ledger across unchanged files and fresh replicas', async () => {
|
|
await fixture(async ({ filePath }) => {
|
|
const first = reviewedKey('issuer-key-1');
|
|
const second = reviewedKey('issuer-key-2');
|
|
let minimumGeneration = 1;
|
|
const observations = [];
|
|
const ledger = {
|
|
async observe(snapshot) {
|
|
observations.push(snapshot);
|
|
if (snapshot.generation < minimumGeneration) {
|
|
throw new Error('durable generation rollback');
|
|
}
|
|
minimumGeneration = snapshot.generation;
|
|
},
|
|
};
|
|
await atomicWrite(filePath, keyset(1, [first]));
|
|
const firstReplica = createClusterPluginPackageIdentityKeysetFile({
|
|
filePath,
|
|
now: () => NOW_MS,
|
|
ledger,
|
|
});
|
|
await firstReplica.reload();
|
|
await firstReplica.reload();
|
|
assert.equal(observations.length, 2);
|
|
|
|
await atomicWrite(filePath, keyset(2, [first, second], ['issuer-key-1']));
|
|
await firstReplica.reload();
|
|
assert.equal(minimumGeneration, 2);
|
|
|
|
await atomicWrite(filePath, keyset(1, [first]));
|
|
const restartedReplica = createClusterPluginPackageIdentityKeysetFile({
|
|
filePath,
|
|
now: () => NOW_MS,
|
|
ledger,
|
|
});
|
|
await assert.rejects(
|
|
restartedReplica.reload(),
|
|
ClusterPluginPackageIdentityKeysetUnavailableError,
|
|
);
|
|
|
|
await atomicWrite(filePath, keyset(2, [first, second], ['issuer-key-1']));
|
|
minimumGeneration = 3;
|
|
await assert.rejects(
|
|
firstReplica.bind(assertion(second)).authenticate(),
|
|
ClusterPluginPackageIdentityKeysetUnavailableError,
|
|
);
|
|
});
|
|
});
|
|
|
|
test('rejects malformed, private and oversized trust documents', async () => {
|
|
await fixture(async ({ filePath }) => {
|
|
const first = reviewedKey('issuer-key-1');
|
|
const provider = createClusterPluginPackageIdentityKeysetFile({
|
|
filePath,
|
|
maxFileBytes: 4 * 1024,
|
|
now: () => NOW_MS,
|
|
});
|
|
|
|
await atomicWrite(filePath, {
|
|
...keyset(1, [first]),
|
|
keys: [{ ...first.publicJwk, d: 'private' }],
|
|
});
|
|
await assert.rejects(provider.reload(), {
|
|
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_UNAVAILABLE',
|
|
});
|
|
|
|
await writeFile(filePath, Buffer.alloc(4 * 1024 + 1, 0x20), {
|
|
mode: 0o644,
|
|
});
|
|
await assert.rejects(provider.reload(), {
|
|
code: 'CLUSTER_PLUGIN_PACKAGE_IDENTITY_KEYSET_UNAVAILABLE',
|
|
});
|
|
});
|
|
});
|