feat: appove and go
This commit is contained in:
@@ -83,7 +83,11 @@ export class ApprovalsService {
|
||||
list(actor: SettingsActor, status = 'pending') {
|
||||
this.requireRbac(actor, 'monitoring', 'read');
|
||||
const res = runFile('python3', [INBOX_CLI, 'list', '--status', status], this.tenantEnv(actor));
|
||||
return { ...parseJson<Record<string, any>>(res.stdout, { count: 0, proposals: [], oversight: [] }), audit_verify: this.verifyAudit(actor) };
|
||||
const inbox = parseJson<{ proposals?: Array<Record<string, any>>; oversight?: Array<Record<string, any>> }>(res.stdout, { proposals: [], oversight: [] });
|
||||
const proposals = (inbox.proposals ?? []).filter((proposal) => this.canAccessProject(actor, String(proposal.project ?? actor.project)));
|
||||
const visibleIds = new Set(proposals.map((proposal) => String(proposal.id ?? '')));
|
||||
const oversight = (inbox.oversight ?? []).filter((record) => visibleIds.has(String(record.proposal_id ?? '')));
|
||||
return { ...inbox, count: proposals.length, proposals, oversight, audit_verify: this.verifyAudit(actor) };
|
||||
}
|
||||
|
||||
submit(input: ApprovalSubmit, actor: SettingsActor) {
|
||||
@@ -122,9 +126,9 @@ export class ApprovalsService {
|
||||
if (!input.id || !input.decision || !input.reason) {
|
||||
throw new ForbiddenException('APPROVAL_DECIDE_DENY id/decision/reason required');
|
||||
}
|
||||
this.requireRbac(actor, 'approval', 'grant');
|
||||
try {
|
||||
const pending = this.findProposal(input.id, actor);
|
||||
this.requireRbac(actor, 'approval', 'grant', String(pending.project ?? actor.project));
|
||||
await this.verifyApprovalIdentity(input, actor, pending);
|
||||
const res = runFile('python3', [
|
||||
INBOX_CLI,
|
||||
@@ -281,7 +285,16 @@ export class ApprovalsService {
|
||||
}
|
||||
}
|
||||
|
||||
private requireRbac(actor: SettingsActor, resource: string, action: string) {
|
||||
private canAccessProject(actor: SettingsActor, targetProject: string): boolean {
|
||||
try {
|
||||
this.requireRbac(actor, 'monitoring', 'read', targetProject);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private requireRbac(actor: SettingsActor, resource: string, action: string, targetProject = actor.project) {
|
||||
try {
|
||||
runFile('python3', [
|
||||
RBAC_CLI,
|
||||
@@ -295,7 +308,7 @@ export class ApprovalsService {
|
||||
'--role-project',
|
||||
actor.project,
|
||||
'--target-project',
|
||||
actor.project,
|
||||
targetProject,
|
||||
'--role-tenant',
|
||||
actor.tenant,
|
||||
'--target-tenant',
|
||||
|
||||
@@ -33,6 +33,14 @@ function roleFromClaim(raw: string | undefined): string {
|
||||
return 'viewer';
|
||||
}
|
||||
|
||||
function projectFromClaims(raw: string | undefined): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
const prefix = 'casan-project:';
|
||||
const projectClaim = raw.split(/[\s,]+/).find((claim) => claim.startsWith(prefix));
|
||||
const project = projectClaim?.slice(prefix.length);
|
||||
return project && /^[A-Za-z0-9._-]+$/.test(project) ? project : undefined;
|
||||
}
|
||||
|
||||
export function actorFromHeaders(headers: Record<string, string | string[] | undefined>): SettingsActor {
|
||||
const actor = firstHeader(headers['x-casan-actor'])
|
||||
|| firstHeader(headers['x-auth-request-user'])
|
||||
@@ -45,7 +53,7 @@ export function actorFromHeaders(headers: Record<string, string | string[] | und
|
||||
return {
|
||||
actor,
|
||||
role: roleFromClaim(roleClaim),
|
||||
project: firstHeader(headers['x-casan-project']) || 'default',
|
||||
project: firstHeader(headers['x-casan-project']) || projectFromClaims(roleClaim) || 'default',
|
||||
tenant: firstHeader(headers['x-casan-tenant']) || 'default',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface GoalJob {
|
||||
finished_at?: string;
|
||||
local_provider: string;
|
||||
local_model: string;
|
||||
effective_local_provider?: string;
|
||||
effective_local_model?: string;
|
||||
cloud_provider: string;
|
||||
cloud_model: string;
|
||||
stages: GoalStage[];
|
||||
@@ -182,9 +184,20 @@ export class GoalsService {
|
||||
// must not silently change provider/model after a direct OpenAI repair
|
||||
// fails. Gateways can have their own transport and model-specific output
|
||||
// contracts; an operator can explicitly opt in after validating one.
|
||||
const gatewayPatchRepairModels = process.env.CASAN_GOAL_ENABLE_GATEWAY_PATCH_REPAIR === '1' ? gatewayModels : [];
|
||||
const gatewayPatchRepairModels = process.env.CASAN_GOAL_ENABLE_GATEWAY_PATCH_REPAIR === '1' ? gatewayModels.slice(0, 1) : [];
|
||||
const preferredCloudModel = cloudCandidates[0]?.model || '';
|
||||
const preferredCloudProvider = preferredCloudModel.startsWith('openai:') ? 'openai' : preferredCloudModel.startsWith('anthropic:') ? 'anthropic' : (cloud?.id || 'unavailable');
|
||||
// Coding-worker order is based on observed capability, not advertised
|
||||
// discovery: direct cloud credentials first, then a logged-in account
|
||||
// bridge, and only then the small local model. The account remains H3 when
|
||||
// direct cloud is H2, preserving a distinct reviewer channel.
|
||||
const cloudWorker = cloudCandidates[0];
|
||||
const accountWorker = cloudWorker ? '' : account;
|
||||
const accountReviewer = cloudWorker ? account : '';
|
||||
const workerModel = cloudWorker?.model || (accountWorker ? `account:${accountWorker}` : String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`));
|
||||
const workerProvider = cloudWorker
|
||||
? (workerModel.startsWith('openai:') ? 'openai' : workerModel.startsWith('anthropic:') ? 'anthropic' : 'cloud')
|
||||
: (accountWorker ? `${accountWorker}-account` : (local?.id || 'local-policy'));
|
||||
const cloudModel = preferredCloudModel || gateway?.defaultModel || gateway?.models[0] || '';
|
||||
const id = randomUUID();
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -199,13 +212,13 @@ export class GoalsService {
|
||||
workspace,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
local_provider: local?.id || 'local-policy',
|
||||
local_model: String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`),
|
||||
cloud_provider: account ? `${account}-account` : (preferredCloudProvider !== 'unavailable' ? preferredCloudProvider : (selectedReviewer?.id || 'unavailable')),
|
||||
cloud_model: account ? `${account}-account-default` : preferredCloudModel || String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''),
|
||||
local_provider: workerProvider,
|
||||
local_model: workerModel,
|
||||
cloud_provider: accountReviewer ? `${accountReviewer}-account` : (preferredCloudProvider !== 'unavailable' ? preferredCloudProvider : (selectedReviewer?.id || 'unavailable')),
|
||||
cloud_model: accountReviewer ? `${accountReviewer}-account-default` : preferredCloudModel || String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''),
|
||||
stages: [
|
||||
{ id: 'local-worker', status: 'queued', detail: 'Waiting for local worker', provider: local?.id || 'local-policy', model: localModel },
|
||||
{ id: 'cloud-reviewer', status: 'queued', detail: account || preferredCloudModel || selectedReviewer ? 'Waiting for independent reviewer' : 'Cloud unavailable; local reviewer will be used', provider: account ? `${account}-account` : (preferredCloudProvider !== 'unavailable' ? preferredCloudProvider : (selectedReviewer?.id || 'local-policy')), model: account ? `${account}-account-default` : cloudModel || localModel },
|
||||
{ id: 'local-worker', status: 'queued', detail: 'Waiting for primary coding worker', provider: workerProvider, model: workerModel },
|
||||
{ id: 'cloud-reviewer', status: 'queued', detail: accountReviewer || preferredCloudModel || selectedReviewer ? 'Waiting for independent reviewer' : 'Cloud unavailable; local reviewer will be used', provider: accountReviewer ? `${accountReviewer}-account` : (preferredCloudProvider !== 'unavailable' ? preferredCloudProvider : (selectedReviewer?.id || 'local-policy')), model: accountReviewer ? `${accountReviewer}-account-default` : cloudModel || localModel },
|
||||
],
|
||||
};
|
||||
const jobFile = this.jobPath(actor.tenant, id);
|
||||
@@ -226,7 +239,7 @@ export class GoalsService {
|
||||
CASAN_GOAL_CLOUD_MODEL: job.cloud_model,
|
||||
CASAN_GOAL_LOCAL_PROVIDER: job.local_provider,
|
||||
CASAN_GOAL_CLOUD_PROVIDER: job.cloud_provider,
|
||||
CASAN_GOAL_ACCOUNT_PROVIDER: account || '',
|
||||
CASAN_GOAL_ACCOUNT_PROVIDER: accountReviewer || '',
|
||||
CASAN_GOAL_CLOUD_FALLBACK_MODEL: String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''),
|
||||
CASAN_GOAL_CLOUD_MODELS: cloudCandidates.map(({ model }) => model).join(','),
|
||||
CASAN_GOAL_OMNIROUTE_MODELS: gatewayModels.join(','),
|
||||
@@ -234,6 +247,8 @@ export class GoalsService {
|
||||
// explicit opt-in because it must not disguise a Codex patch failure.
|
||||
CASAN_GOAL_PATCH_REPAIR_MODELS: [...cloudCandidates.map(({ model }) => model), ...gatewayPatchRepairModels, String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`)].filter((model, index, rows) => rows.indexOf(model) === index).join(','),
|
||||
CASAN_GOAL_LOCAL_REVIEWER_MODEL: String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`),
|
||||
CASAN_GOAL_LOCAL_REVIEWER_PROVIDER: local?.id || 'local-policy',
|
||||
CASAN_GOAL_ENABLE_LOCAL_REVIEWER: process.env.CASAN_GOAL_ENABLE_LOCAL_REVIEWER || (account || preferredCloudModel ? '0' : '1'),
|
||||
CASAN_GOAL_REVIEWER_MAX_ATTEMPTS: process.env.CASAN_GOAL_REVIEWER_MAX_ATTEMPTS || '8',
|
||||
CASAN_GOAL_REVIEWER_DEADLINE_SEC: process.env.CASAN_GOAL_REVIEWER_DEADLINE_SEC || '600',
|
||||
},
|
||||
@@ -393,7 +408,8 @@ export class GoalsService {
|
||||
|
||||
apply(id: string, actor: SettingsActor): GoalJob {
|
||||
const job = this.get(id, actor);
|
||||
if (!job.patch_artifact || job.status !== 'requires_approval') {
|
||||
const retryableRollback = job.status === 'failed' && job.error === 'GOAL_APPLY_VERIFICATION_FAILED_ROLLED_BACK';
|
||||
if (!job.patch_artifact || (job.status !== 'requires_approval' && !retryableRollback)) {
|
||||
throw new BadRequestException('GOAL_PATCH_NOT_READY');
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -102,6 +102,29 @@ test('operations owner request remains visible and actionable for an independent
|
||||
});
|
||||
});
|
||||
|
||||
test('project reviewer cannot see or decide another project approval', async () => {
|
||||
await withTempGovernance(async () => {
|
||||
const svc = new ApprovalsService();
|
||||
const submitted = svc.submit({
|
||||
action: 'goal.workspace.execute',
|
||||
target: 'project-alpha',
|
||||
risk: 'high',
|
||||
sensitive: true,
|
||||
reason: 'project alpha patch',
|
||||
payload: { goal_id: 'goal-alpha', project_id: 'project-alpha' },
|
||||
}, { ...projectAdmin, project: 'project-alpha' }) as { proposal: { id: string } };
|
||||
const otherProjectReviewer = { ...approver, project: 'project-beta' };
|
||||
|
||||
const reviewerInbox = svc.list(otherProjectReviewer, 'pending') as { count: number; proposals: Array<{ id: string }> };
|
||||
assert.equal(reviewerInbox.count, 0);
|
||||
assert.equal(reviewerInbox.proposals.length, 0);
|
||||
await assert.rejects(
|
||||
svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'wrong project' }, otherProjectReviewer),
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('approval inbox denies forged JWT in strict mode without deciding proposal', async () => {
|
||||
await withTempGovernance(async ({ inbox }) => {
|
||||
const svc = new ApprovalsService();
|
||||
|
||||
@@ -15,10 +15,11 @@ test('auth context keeps local explicit roles for dev', () => {
|
||||
test('auth context maps IdP group claim to RBAC role', () => {
|
||||
const actor = actorFromHeaders({
|
||||
'x-auth-request-user': 'bob@example.com',
|
||||
'x-auth-request-groups': 'engineering,casan-approver',
|
||||
'x-auth-request-groups': 'engineering,casan-approver,casan-project:AINative_OKR_CASAN4',
|
||||
});
|
||||
assert.equal(actor.actor, 'bob@example.com');
|
||||
assert.equal(actor.role, 'approver');
|
||||
assert.equal(actor.project, 'AINative_OKR_CASAN4');
|
||||
});
|
||||
|
||||
test('auth context fails closed to viewer for unknown role claim', () => {
|
||||
|
||||
@@ -99,19 +99,39 @@ export function Approvals() {
|
||||
});
|
||||
|
||||
const decide = useMutation({
|
||||
mutationFn: ({ id, decision }: { id: string; decision: 'approve' | 'reject' }) => {
|
||||
mutationFn: async ({ proposal, decision }: { proposal: ApprovalProposal; decision: 'approve' | 'reject' }) => {
|
||||
if (!actor) throw new Error('Authenticated session is unavailable.');
|
||||
return api.decideApproval(actor, { id, decision, reason: decisionReason.trim() });
|
||||
const response = await api.decideApproval(actor, { id: proposal.id, decision, reason: decisionReason.trim() });
|
||||
if (decision !== 'approve' || proposal.action !== 'goal.workspace.execute') {
|
||||
return { response, continuedGoalId: null };
|
||||
}
|
||||
|
||||
const goalId = proposal.payload.goal_id;
|
||||
if (typeof goalId !== 'string' || goalId.length === 0) {
|
||||
throw new Error(`Request ${proposal.id} was approved, but its goal identifier is missing. Continue from Goal Orchestrator.`);
|
||||
}
|
||||
try {
|
||||
await api.applyGoal({ ...actor, project: proposal.project }, goalId);
|
||||
return { response, continuedGoalId: goalId };
|
||||
} catch (error) {
|
||||
throw new Error(`Request ${proposal.id} was approved, but apply/verification did not complete: ${errorMessage(error, 'unknown apply error')}`);
|
||||
}
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
onSuccess: ({ response, continuedGoalId }) => {
|
||||
const verb = response.proposal.status === 'approved' ? 'approved' : 'rejected';
|
||||
setNotice({ tone: 'success', text: `Request ${response.proposal.id} was ${verb}.` });
|
||||
const continuation = continuedGoalId ? ' The governed change was applied and verified.' : '';
|
||||
setNotice({ tone: 'success', text: `Request ${response.proposal.id} was ${verb}.${continuation}` });
|
||||
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['goal'] });
|
||||
},
|
||||
onError: (error) => setNotice({ tone: 'error', text: errorMessage(error, 'The approval decision could not be recorded.') }),
|
||||
onError: (error) => {
|
||||
setNotice({ tone: 'error', text: errorMessage(error, 'The approval decision could not be recorded.') });
|
||||
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['goal'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (session.isLoading || (actor && inbox.isLoading)) {
|
||||
@@ -184,7 +204,7 @@ export function Approvals() {
|
||||
<div className="mt-5 space-y-3">
|
||||
{inbox.data.proposals.map((proposal) => {
|
||||
const eligibility = reviewEligibility(actor, proposal);
|
||||
const isCurrentDecision = decide.isPending && decide.variables?.id === proposal.id;
|
||||
const isCurrentDecision = decide.isPending && decide.variables?.proposal.id === proposal.id;
|
||||
return (
|
||||
<article key={proposal.id} className="rounded-2xl border border-slate-200 bg-white p-4 transition hover:border-slate-300 hover:shadow-[0_12px_28px_rgba(15,23,42,0.055)] sm:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
@@ -211,8 +231,8 @@ export function Approvals() {
|
||||
<span className={`text-xs font-medium ${eligibility.allowed ? 'text-emerald-700' : 'text-amber-700'}`}>{eligibility.reason}</span>
|
||||
{eligibility.allowed && (
|
||||
<div className="flex gap-2">
|
||||
<button type="button" disabled={decide.isPending || decisionReason.trim().length < 5} onClick={() => decide.mutate({ id: proposal.id, decision: 'reject' })} className="rounded-lg border border-rose-200 bg-white px-3.5 py-2 text-xs font-semibold text-rose-700 transition hover:bg-rose-50 focus:outline-none focus:ring-4 focus:ring-rose-100 disabled:cursor-not-allowed disabled:opacity-40">{isCurrentDecision && decide.variables?.decision === 'reject' ? 'Rejecting…' : 'Reject'}</button>
|
||||
<button type="button" disabled={decide.isPending || decisionReason.trim().length < 5} onClick={() => decide.mutate({ id: proposal.id, decision: 'approve' })} className="rounded-lg bg-emerald-700 px-4 py-2 text-xs font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-4 focus:ring-emerald-100 disabled:cursor-not-allowed disabled:bg-slate-300">{isCurrentDecision && decide.variables?.decision === 'approve' ? 'Approving…' : 'Approve'}</button>
|
||||
<button type="button" disabled={decide.isPending || decisionReason.trim().length < 5} onClick={() => decide.mutate({ proposal, decision: 'reject' })} className="rounded-lg border border-rose-200 bg-white px-3.5 py-2 text-xs font-semibold text-rose-700 transition hover:bg-rose-50 focus:outline-none focus:ring-4 focus:ring-rose-100 disabled:cursor-not-allowed disabled:opacity-40">{isCurrentDecision && decide.variables?.decision === 'reject' ? 'Rejecting…' : 'Reject'}</button>
|
||||
<button type="button" disabled={decide.isPending || decisionReason.trim().length < 5} onClick={() => decide.mutate({ proposal, decision: 'approve' })} className="rounded-lg bg-emerald-700 px-4 py-2 text-xs font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-4 focus:ring-emerald-100 disabled:cursor-not-allowed disabled:bg-slate-300">{isCurrentDecision && decide.variables?.decision === 'approve' ? 'Approving and continuing…' : proposal.action === 'goal.workspace.execute' ? 'Approve & continue' : 'Approve'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user