fix(mosaic): fail closed across lifecycle audit faults
This commit is contained in:
@@ -504,23 +504,6 @@ export async function executeCredentialRotate(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const context = await lifecycleContext(options);
|
const context = await lifecycleContext(options);
|
||||||
const old = await context.store.snapshot(identity, options.estate, options.host);
|
|
||||||
if (old === undefined) {
|
|
||||||
return localLifecycleResult('rotate', identity, options, {
|
|
||||||
outcome: 'refused',
|
|
||||||
code: 'no-token-for-identity',
|
|
||||||
message: 'No existing binding can be rotated.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (options.tokenName === old.binding.tokenName) {
|
|
||||||
old.secret.fill(0);
|
|
||||||
return localLifecycleResult('rotate', identity, options, {
|
|
||||||
outcome: 'refused',
|
|
||||||
code: 'replacement-token-name-conflict',
|
|
||||||
message: 'Replacement token name must differ from the active generation.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const authority = await lifecycleAuthority(identity, options);
|
|
||||||
const journal = await CredentialAuditJournal.open(context.stateRoot, {
|
const journal = await CredentialAuditJournal.open(context.stateRoot, {
|
||||||
operation: 'rotate',
|
operation: 'rotate',
|
||||||
actor: options.actor,
|
actor: options.actor,
|
||||||
@@ -530,6 +513,25 @@ export async function executeCredentialRotate(
|
|||||||
repo: null,
|
repo: null,
|
||||||
});
|
});
|
||||||
await journal.recordIntent('rotate-requested');
|
await journal.recordIntent('rotate-requested');
|
||||||
|
const old = await context.store.snapshot(identity, options.estate, options.host);
|
||||||
|
if (old === undefined) {
|
||||||
|
await journal.seal('refused', 'no-token-for-identity');
|
||||||
|
return localLifecycleResult('rotate', identity, options, {
|
||||||
|
outcome: 'refused',
|
||||||
|
code: 'no-token-for-identity',
|
||||||
|
message: 'No existing binding can be rotated.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options.tokenName === old.binding.tokenName) {
|
||||||
|
old.secret.fill(0);
|
||||||
|
await journal.seal('refused', 'replacement-token-name-conflict');
|
||||||
|
return localLifecycleResult('rotate', identity, options, {
|
||||||
|
outcome: 'refused',
|
||||||
|
code: 'replacement-token-name-conflict',
|
||||||
|
message: 'Replacement token name must differ from the active generation.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const authority = await lifecycleAuthority(identity, options);
|
||||||
const provisioned = await provisionCredential(
|
const provisioned = await provisionCredential(
|
||||||
{
|
{
|
||||||
identity,
|
identity,
|
||||||
@@ -655,16 +657,17 @@ export async function executeCredentialWire(
|
|||||||
message: 'Declared estate and host could not be resolved before mutation.',
|
message: 'Declared estate and host could not be resolved before mutation.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
let journal: CredentialAuditJournal | undefined;
|
||||||
operation: 'wire',
|
|
||||||
actor: options.actor,
|
|
||||||
identity,
|
|
||||||
estate: options.estate,
|
|
||||||
host: options.host,
|
|
||||||
repo: null,
|
|
||||||
});
|
|
||||||
await journal.recordIntent('wire-requested');
|
|
||||||
try {
|
try {
|
||||||
|
journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||||
|
operation: 'wire',
|
||||||
|
actor: options.actor,
|
||||||
|
identity,
|
||||||
|
estate: options.estate,
|
||||||
|
host: options.host,
|
||||||
|
repo: null,
|
||||||
|
});
|
||||||
|
await journal.recordIntent('wire-requested');
|
||||||
let existing = '';
|
let existing = '';
|
||||||
try {
|
try {
|
||||||
const snapshot = readRegularFileSecure(options.seatEnv, {
|
const snapshot = readRegularFileSecure(options.seatEnv, {
|
||||||
@@ -719,15 +722,32 @@ export async function executeCredentialWire(
|
|||||||
message: 'Both fleet identity axes were written to the explicit seat environment.',
|
message: 'Both fleet identity axes were written to the explicit seat environment.',
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error: unknown) {
|
||||||
await journal.seal('error', 'wire-failed');
|
if (journal === undefined) {
|
||||||
return localLifecycleResult('wire', identity, options, {
|
return localLifecycleResult('wire', identity, options, {
|
||||||
outcome: 'error',
|
outcome: 'error',
|
||||||
mutation: 'unknown',
|
code: error instanceof CredentialJournalError ? error.code : 'wire-failed',
|
||||||
code: 'wire-failed',
|
message: 'Seat environment journal could not be opened durably.',
|
||||||
message: 'Seat environment wiring failed.',
|
});
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
}
|
||||||
});
|
try {
|
||||||
|
await journal.seal('error', 'wire-failed');
|
||||||
|
return localLifecycleResult('wire', identity, options, {
|
||||||
|
outcome: 'error',
|
||||||
|
mutation: 'unknown',
|
||||||
|
code: 'wire-failed',
|
||||||
|
message: 'Seat environment wiring failed.',
|
||||||
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
|
});
|
||||||
|
} catch (sealError: unknown) {
|
||||||
|
return localLifecycleResult('wire', identity, options, {
|
||||||
|
outcome: 'error',
|
||||||
|
mutation: 'unknown',
|
||||||
|
code: sealError instanceof CredentialJournalError ? sealError.code : 'wire-failed',
|
||||||
|
message: 'Seat environment audit could not be sealed.',
|
||||||
|
audit: { journalId: journal.journalId(), state: 'open' },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,16 +756,17 @@ export async function executeCredentialGet(
|
|||||||
options: CredentialLifecycleCommandOptions,
|
options: CredentialLifecycleCommandOptions,
|
||||||
): Promise<CredentialLifecycleResultDto> {
|
): Promise<CredentialLifecycleResultDto> {
|
||||||
const locations = lifecycleLocations(options);
|
const locations = lifecycleLocations(options);
|
||||||
const journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
let journal: CredentialAuditJournal | undefined;
|
||||||
operation: 'get',
|
|
||||||
actor: options.actor,
|
|
||||||
identity,
|
|
||||||
estate: options.estate,
|
|
||||||
host: options.host,
|
|
||||||
repo: null,
|
|
||||||
});
|
|
||||||
await journal.recordIntent('get-requested');
|
|
||||||
try {
|
try {
|
||||||
|
journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||||
|
operation: 'get',
|
||||||
|
actor: options.actor,
|
||||||
|
identity,
|
||||||
|
estate: options.estate,
|
||||||
|
host: options.host,
|
||||||
|
repo: null,
|
||||||
|
});
|
||||||
|
await journal.recordIntent('get-requested');
|
||||||
if (options.authorityFd === undefined || options.actor !== identity) {
|
if (options.authorityFd === undefined || options.actor !== identity) {
|
||||||
await journal.seal('refused', 'provider-identity-mismatch');
|
await journal.seal('refused', 'provider-identity-mismatch');
|
||||||
return localLifecycleResult('get', identity, options, {
|
return localLifecycleResult('get', identity, options, {
|
||||||
@@ -810,14 +831,30 @@ export async function executeCredentialGet(
|
|||||||
message: 'Credential was emitted only to the protected output fd.',
|
message: 'Credential was emitted only to the protected output fd.',
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error: unknown) {
|
||||||
await journal.seal('error', 'insecure-credential-destination');
|
if (journal === undefined) {
|
||||||
return localLifecycleResult('get', identity, options, {
|
return localLifecycleResult('get', identity, options, {
|
||||||
outcome: 'error',
|
outcome: 'error',
|
||||||
code: 'insecure-credential-destination',
|
code: error instanceof CredentialJournalError ? error.code : 'journal-unavailable',
|
||||||
message: 'Protected credential output fd was unavailable or unsafe.',
|
message: 'Credential access journal could not be opened durably.',
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
});
|
||||||
});
|
}
|
||||||
|
try {
|
||||||
|
await journal.seal('error', 'insecure-credential-destination');
|
||||||
|
return localLifecycleResult('get', identity, options, {
|
||||||
|
outcome: 'error',
|
||||||
|
code: 'insecure-credential-destination',
|
||||||
|
message: 'Protected credential output fd was unavailable or unsafe.',
|
||||||
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
|
});
|
||||||
|
} catch (sealError: unknown) {
|
||||||
|
return localLifecycleResult('get', identity, options, {
|
||||||
|
outcome: 'error',
|
||||||
|
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
|
||||||
|
message: 'Credential access audit could not be sealed.',
|
||||||
|
audit: { journalId: journal.journalId(), state: 'open' },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,16 +863,17 @@ async function executeAuthorizedInventoryRead(
|
|||||||
options: CredentialLifecycleCommandOptions,
|
options: CredentialLifecycleCommandOptions,
|
||||||
): Promise<CredentialLifecycleResultDto> {
|
): Promise<CredentialLifecycleResultDto> {
|
||||||
const locations = lifecycleLocations(options);
|
const locations = lifecycleLocations(options);
|
||||||
const journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
let journal: CredentialAuditJournal | undefined;
|
||||||
operation,
|
|
||||||
actor: options.actor,
|
|
||||||
identity: options.actor,
|
|
||||||
estate: options.estate,
|
|
||||||
host: options.host,
|
|
||||||
repo: null,
|
|
||||||
});
|
|
||||||
await journal.recordIntent(`${operation}-requested`);
|
|
||||||
try {
|
try {
|
||||||
|
journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||||
|
operation,
|
||||||
|
actor: options.actor,
|
||||||
|
identity: options.actor,
|
||||||
|
estate: options.estate,
|
||||||
|
host: options.host,
|
||||||
|
repo: null,
|
||||||
|
});
|
||||||
|
await journal.recordIntent(`${operation}-requested`);
|
||||||
if (options.authorityFd === undefined) {
|
if (options.authorityFd === undefined) {
|
||||||
await journal.seal('refused', 'authority-required');
|
await journal.seal('refused', 'authority-required');
|
||||||
return localLifecycleResult(operation, 'all', options, {
|
return localLifecycleResult(operation, 'all', options, {
|
||||||
@@ -891,14 +929,30 @@ async function executeAuthorizedInventoryRead(
|
|||||||
},
|
},
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (error: unknown) {
|
||||||
await journal.seal('error', 'inventory-read-failed');
|
if (journal === undefined) {
|
||||||
return localLifecycleResult(operation, 'all', options, {
|
return localLifecycleResult(operation, 'all', options, {
|
||||||
outcome: 'error',
|
outcome: 'error',
|
||||||
code: 'inventory-read-failed',
|
code: error instanceof CredentialJournalError ? error.code : 'journal-unavailable',
|
||||||
message: 'Authorized credential inventory read failed.',
|
message: 'Inventory journal could not be opened durably.',
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
});
|
||||||
});
|
}
|
||||||
|
try {
|
||||||
|
await journal.seal('error', 'inventory-read-failed');
|
||||||
|
return localLifecycleResult(operation, 'all', options, {
|
||||||
|
outcome: 'error',
|
||||||
|
code: 'inventory-read-failed',
|
||||||
|
message: 'Authorized credential inventory read failed.',
|
||||||
|
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||||
|
});
|
||||||
|
} catch (sealError: unknown) {
|
||||||
|
return localLifecycleResult(operation, 'all', options, {
|
||||||
|
outcome: 'error',
|
||||||
|
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
|
||||||
|
message: 'Inventory audit could not be sealed.',
|
||||||
|
audit: { journalId: journal.journalId(), state: 'open' },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,25 @@ export class FileCredentialResolver implements CredentialResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertPrivateTokenDirectory(path: string): {
|
||||||
|
readonly dev: number | bigint;
|
||||||
|
readonly ino: number | bigint;
|
||||||
|
} {
|
||||||
|
const stat = lstatSync(path);
|
||||||
|
if (
|
||||||
|
!stat.isDirectory() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
stat.uid !== process.getuid?.() ||
|
||||||
|
(stat.mode & 0o022) !== 0
|
||||||
|
) {
|
||||||
|
throw new CredentialStoreError(
|
||||||
|
'insecure-token-owner',
|
||||||
|
'token directory ownership or write permissions are unsafe',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { dev: stat.dev, ino: stat.ino };
|
||||||
|
}
|
||||||
|
|
||||||
async function syncDirectory(path: string): Promise<void> {
|
async function syncDirectory(path: string): Promise<void> {
|
||||||
const handle = await open(path, 'r');
|
const handle = await open(path, 'r');
|
||||||
try {
|
try {
|
||||||
@@ -269,6 +288,7 @@ export class FileCredentialStore {
|
|||||||
async put(metadata: CredentialBindingMetadataDto, secret: Uint8Array): Promise<void> {
|
async put(metadata: CredentialBindingMetadataDto, secret: Uint8Array): Promise<void> {
|
||||||
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
|
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
|
||||||
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
|
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
|
||||||
|
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
|
||||||
const token = validateSecret(Buffer.from(secret));
|
const token = validateSecret(Buffer.from(secret));
|
||||||
const envelope = credentialEnvelopeSchema.parse({
|
const envelope = credentialEnvelopeSchema.parse({
|
||||||
...metadata,
|
...metadata,
|
||||||
@@ -296,6 +316,16 @@ export class FileCredentialStore {
|
|||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
await handle.close();
|
||||||
}
|
}
|
||||||
|
const beforeCommit = assertPrivateTokenDirectory(this.tokenDirectory);
|
||||||
|
if (
|
||||||
|
beforeCommit.dev !== directoryIdentity.dev ||
|
||||||
|
beforeCommit.ino !== directoryIdentity.ino
|
||||||
|
) {
|
||||||
|
throw new CredentialStoreError(
|
||||||
|
'insecure-token-owner',
|
||||||
|
'token directory changed during credential commit',
|
||||||
|
);
|
||||||
|
}
|
||||||
await rename(envelopeTemp, paths.envelope);
|
await rename(envelopeTemp, paths.envelope);
|
||||||
await syncDirectory(this.tokenDirectory);
|
await syncDirectory(this.tokenDirectory);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -424,6 +454,7 @@ export class FileCredentialStore {
|
|||||||
expectedTokenDigest?: string,
|
expectedTokenDigest?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const paths = this.paths(identity, estate, host);
|
const paths = this.paths(identity, estate, host);
|
||||||
|
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
|
||||||
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
|
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
|
||||||
let lock;
|
let lock;
|
||||||
try {
|
try {
|
||||||
@@ -444,6 +475,16 @@ export class FileCredentialStore {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const beforeRemoval = assertPrivateTokenDirectory(this.tokenDirectory);
|
||||||
|
if (
|
||||||
|
beforeRemoval.dev !== directoryIdentity.dev ||
|
||||||
|
beforeRemoval.ino !== directoryIdentity.ino
|
||||||
|
) {
|
||||||
|
throw new CredentialStoreError(
|
||||||
|
'insecure-token-owner',
|
||||||
|
'token directory changed during credential 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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -389,6 +389,33 @@ export class GiteaGrantProviderAdapter
|
|||||||
extends GiteaCredentialProviderAdapter
|
extends GiteaCredentialProviderAdapter
|
||||||
implements GiteaGrantProvider
|
implements GiteaGrantProvider
|
||||||
{
|
{
|
||||||
|
async readBasicIdentity(authority: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
|
||||||
|
const endpoint = 'GET /api/v1/user';
|
||||||
|
const response = await this.request(`${this.origin}/api/v1/user`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: JSON_CONTENT_TYPE,
|
||||||
|
Authorization: basicAuthorization(authority),
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
await boundedBody(response);
|
||||||
|
throw new CredentialProviderEvidenceError(
|
||||||
|
'credential-rejected',
|
||||||
|
'delegated Basic authority was rejected',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parsed = userSchema.safeParse(await jsonObject(response));
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new CredentialProviderEvidenceError(
|
||||||
|
'unexpected-provider-shape',
|
||||||
|
'delegated Basic identity response was malformed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { login: parsed.data.login, endpoint, contentType: contentType(response) };
|
||||||
|
}
|
||||||
|
|
||||||
async grantCollaborator(
|
async grantCollaborator(
|
||||||
authority: ResolvedCredential,
|
authority: ResolvedCredential,
|
||||||
identity: string,
|
identity: string,
|
||||||
@@ -402,7 +429,7 @@ export class GiteaGrantProviderAdapter
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: JSON_CONTENT_TYPE,
|
Accept: JSON_CONTENT_TYPE,
|
||||||
Authorization: apiAuthorization(authority),
|
Authorization: basicAuthorization(authority),
|
||||||
'Content-Type': JSON_CONTENT_TYPE,
|
'Content-Type': JSON_CONTENT_TYPE,
|
||||||
'User-Agent': USER_AGENT,
|
'User-Agent': USER_AGENT,
|
||||||
},
|
},
|
||||||
@@ -433,7 +460,7 @@ export class GiteaGrantProviderAdapter
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: JSON_CONTENT_TYPE,
|
Accept: JSON_CONTENT_TYPE,
|
||||||
Authorization: apiAuthorization(authority),
|
Authorization: basicAuthorization(authority),
|
||||||
'User-Agent': USER_AGENT,
|
'User-Agent': USER_AGENT,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -518,7 +545,7 @@ export class GiteaTeamGrantProviderAdapter
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: JSON_CONTENT_TYPE,
|
Accept: JSON_CONTENT_TYPE,
|
||||||
Authorization: apiAuthorization(authority),
|
Authorization: basicAuthorization(authority),
|
||||||
'User-Agent': USER_AGENT,
|
'User-Agent': USER_AGENT,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -557,7 +584,7 @@ export class GiteaTeamGrantProviderAdapter
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: JSON_CONTENT_TYPE,
|
Accept: JSON_CONTENT_TYPE,
|
||||||
Authorization: apiAuthorization(authority),
|
Authorization: basicAuthorization(authority),
|
||||||
'User-Agent': USER_AGENT,
|
'User-Agent': USER_AGENT,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -613,7 +640,7 @@ export class GiteaTeamGrantProviderAdapter
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: JSON_CONTENT_TYPE,
|
Accept: JSON_CONTENT_TYPE,
|
||||||
Authorization: apiAuthorization(authority),
|
Authorization: basicAuthorization(authority),
|
||||||
'User-Agent': USER_AGENT,
|
'User-Agent': USER_AGENT,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -666,7 +693,7 @@ export class GiteaTeamGrantProviderAdapter
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: JSON_CONTENT_TYPE,
|
Accept: JSON_CONTENT_TYPE,
|
||||||
Authorization: apiAuthorization(authority),
|
Authorization: basicAuthorization(authority),
|
||||||
'User-Agent': USER_AGENT,
|
'User-Agent': USER_AGENT,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ describe('direct repository grant', (): void => {
|
|||||||
it('opens the journal before mutation and accepts only matching provider read-back', async (): Promise<void> => {
|
it('opens the journal before mutation and accepts only matching provider read-back', async (): Promise<void> => {
|
||||||
const root = await stateRoot();
|
const root = await stateRoot();
|
||||||
const provider: GiteaGrantProvider = {
|
const provider: GiteaGrantProvider = {
|
||||||
async readIdentity() {
|
async readBasicIdentity() {
|
||||||
return {
|
return {
|
||||||
login: 'provisioner',
|
login: 'provisioner',
|
||||||
endpoint: 'GET /api/v1/user',
|
endpoint: 'GET /api/v1/user',
|
||||||
@@ -142,7 +142,7 @@ describe('direct repository grant', (): void => {
|
|||||||
it('preserves applied mutation and journal context when post-grant read-back fails', async (): Promise<void> => {
|
it('preserves applied mutation and journal context when post-grant read-back fails', async (): Promise<void> => {
|
||||||
const root = await stateRoot();
|
const root = await stateRoot();
|
||||||
const provider: GiteaGrantProvider = {
|
const provider: GiteaGrantProvider = {
|
||||||
async readIdentity() {
|
async readBasicIdentity() {
|
||||||
return {
|
return {
|
||||||
login: 'provisioner',
|
login: 'provisioner',
|
||||||
endpoint: 'GET /api/v1/user',
|
endpoint: 'GET /api/v1/user',
|
||||||
@@ -183,7 +183,7 @@ describe('direct repository grant', (): void => {
|
|||||||
it('is indeterminate when grant read-back disagrees with the requested permission', async (): Promise<void> => {
|
it('is indeterminate when grant read-back disagrees with the requested permission', async (): Promise<void> => {
|
||||||
const root = await stateRoot();
|
const root = await stateRoot();
|
||||||
const provider: GiteaGrantProvider = {
|
const provider: GiteaGrantProvider = {
|
||||||
async readIdentity() {
|
async readBasicIdentity() {
|
||||||
return {
|
return {
|
||||||
login: 'provisioner',
|
login: 'provisioner',
|
||||||
endpoint: 'GET /api/v1/user',
|
endpoint: 'GET /api/v1/user',
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
|
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
|
||||||
|
|
||||||
export interface GiteaGrantProvider {
|
export interface GiteaGrantProvider {
|
||||||
readIdentity(authority: ResolvedCredential): Promise<{
|
readBasicIdentity(authority: ResolvedCredential): Promise<{
|
||||||
readonly login: string;
|
readonly login: string;
|
||||||
readonly endpoint: string;
|
readonly endpoint: string;
|
||||||
readonly contentType: string;
|
readonly contentType: string;
|
||||||
@@ -76,7 +76,7 @@ export async function grantDirectRepositoryPermission(
|
|||||||
await journal.recordIntent('provider-grant');
|
await journal.recordIntent('provider-grant');
|
||||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||||
try {
|
try {
|
||||||
const authorityIdentity = await grantProvider.readIdentity(authority);
|
const authorityIdentity = await grantProvider.readBasicIdentity(authority);
|
||||||
if (authorityIdentity.login !== options.actor) {
|
if (authorityIdentity.login !== options.actor) {
|
||||||
await journal.seal('refused', 'provider-identity-mismatch');
|
await journal.seal('refused', 'provider-identity-mismatch');
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ describe('team repository grant', (): void => {
|
|||||||
it('reads team permission, org membership, member attachment, repo attachment, and effective subject permission', async (): Promise<void> => {
|
it('reads team permission, org membership, member attachment, repo attachment, and effective subject permission', async (): Promise<void> => {
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||||
const provider: GiteaTeamGrantProvider = {
|
const provider: GiteaTeamGrantProvider = {
|
||||||
async readIdentity() {
|
async readBasicIdentity() {
|
||||||
return {
|
return {
|
||||||
login: 'provisioner',
|
login: 'provisioner',
|
||||||
endpoint: 'GET /api/v1/user',
|
endpoint: 'GET /api/v1/user',
|
||||||
@@ -148,7 +148,7 @@ describe('team repository grant', (): void => {
|
|||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
||||||
let mutated = false;
|
let mutated = false;
|
||||||
const provider: GiteaTeamGrantProvider = {
|
const provider: GiteaTeamGrantProvider = {
|
||||||
async readIdentity() {
|
async readBasicIdentity() {
|
||||||
return {
|
return {
|
||||||
login: 'provisioner',
|
login: 'provisioner',
|
||||||
endpoint: 'GET /api/v1/user',
|
endpoint: 'GET /api/v1/user',
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export interface TeamGrantResult extends CredentialGrantResultDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
export interface GiteaTeamGrantProvider {
|
export interface GiteaTeamGrantProvider {
|
||||||
readIdentity(
|
readBasicIdentity(
|
||||||
authority: ResolvedCredential,
|
authority: ResolvedCredential,
|
||||||
): Promise<{ readonly login: string; readonly endpoint: string; readonly contentType: string }>;
|
): Promise<{ readonly login: string; readonly endpoint: string; readonly contentType: string }>;
|
||||||
resolveTeam(
|
resolveTeam(
|
||||||
@@ -93,7 +93,7 @@ export async function grantTeamRepositoryPermission(
|
|||||||
await journal.recordIntent('provider-grant');
|
await journal.recordIntent('provider-grant');
|
||||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||||
try {
|
try {
|
||||||
const authorityIdentity = await provider.readIdentity(authority);
|
const authorityIdentity = await provider.readBasicIdentity(authority);
|
||||||
const organization = request.repo.split('/')[0] ?? '';
|
const organization = request.repo.split('/')[0] ?? '';
|
||||||
const team = await provider.resolveTeam(authority, organization, request.team);
|
const team = await provider.resolveTeam(authority, organization, request.team);
|
||||||
if (authorityIdentity.login !== options.actor || team.permission !== request.permission) {
|
if (authorityIdentity.login !== options.actor || team.permission !== request.permission) {
|
||||||
|
|||||||
Reference in New Issue
Block a user