diff --git a/CHANGELOG.md b/CHANGELOG.md index b78e3be4d..3e005e64b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Security +- Require explicit organization-tenant authority when either the destination account scope is organization-owned or the canonical organization-sensitive review reason is present; fail closed in both frontend projection and durable Rust transfer authorization even when the ordinary review flag is absent, and regression-test contradictory signal combinations. - Enable an explicit fail-closed Tauri Content Security Policy to keep executable scripts and fonts local, grant production network authority only to the Tauri IPC transport, confine Vite WebSocket HMR to a separate development-only CSP, deny object/frame/base-URI authority, deny form submissions with explicit `form-action 'none'`, deny unused worker, media, and web-app-manifest fetch authority with explicit `'none'` directives, and regression-test against null, wildcard, remote-script/style, eval, and development-authority leakage. - Re-verify the installed GGUF immediately before llama.cpp initialization and retain the verified model handle through llama.cpp loading: reject missing, linked, non-regular, identity-raced, short, oversized, unreadable, or SHA-256-mismatched artifacts with stable path-free errors; use a stable descriptor path on Unix and a Windows read-sharing guard so the mutable source pathname cannot be substituted between verification and model parsing. - Bind the default on-device GGUF model to an immutable upstream revision, exact byte count, and SHA-256 digest; replace whole-model buffering and named sibling staging with bounded streaming into an unnamed same-directory temporary file; ignore and preserve unrelated legacy `.part` paths; refuse destination overwrite with create-new semantics; capture destination ownership from the returned open file handle; re-read and rehash the still-open staging source while copying; flush, sync, re-read, and rehash the destination before final acceptance; reject same-file source or destination mutation; preserve foreign destination replacements through identity-bound cleanup; and keep model installation inside the Rust coverage surface with privacy-safe stable errors and deterministic race regressions. diff --git a/docs/architecture/cloud-review-tenant-authority.md b/docs/architecture/cloud-review-tenant-authority.md new file mode 100644 index 000000000..93a1c5f0f --- /dev/null +++ b/docs/architecture/cloud-review-tenant-authority.md @@ -0,0 +1,64 @@ +# Cloud review tenant-authority decision + +## Status + +Accepted for the cloud review queue and durable Rust transfer gate. This document records both the frontend projection and the trusted mutation boundary; the frontend remains incapable of granting durable mutation authorization. + +## Context + +A cloud candidate carries two independent signals that an approval needs organization-tenant authority: + +1. `destination_account_scope` identifies an organization destination. +2. `review_reasons` contains `organization-cloud-sensitive-context-needs-explicit-tenant-approval` when the candidate evidence requires explicit tenant review. + +The previous predicate required both signals simultaneously. A missing, contradictory, stale, or malformed value in either field therefore made the approval path less restrictive. An approved decision with no tenant-authority attestation could become execution-ready even though the remaining signal still identified organization-sensitive handling. A candidate whose ordinary `requires_review` flag was false could also bypass the tenant-authority requirement entirely. + +This is an incorrect-authorization pattern: an authorization decision must not become more permissive because one of two security attributes is absent or contradictory. NIST SP 800-53 AC-3 requires access enforcement according to applicable policy, OWASP ASVS 5.0.0 treats authorization as an independently verified security control, and CWE-863 describes the broader weakness class in which an authorization check is performed incorrectly. + +## Decision + +Both TypeScript review projection and Rust transfer authorization use fail-closed disjunction: + +```text +organization destination scope +OR organization-sensitive tenant review reason +=> explicit organization-tenant authority attestation required +``` + +Either signal is sufficient. Only a candidate with neither signal follows the ordinary approval contract. + +An approved decision is accepted only when its rationale starts with the exact backend-defined marker `[organization-tenant-authority-confirmed]` followed by exactly one U+0020 ASCII space whenever the predicate is true. Held decisions remain admissible without the marker because they grant no execution-ready approval. If either organization signal is present while `requires_review` is false, both frontend and Rust fail closed instead of treating the candidate as ready. Candidate and decision fingerprints, reviewer attribution, rationale validation, copy-approval freshness, exact confirmation phrase, provider/account scope, and all other durable Rust authorization checks remain mandatory and independent. + +## Security invariants + +- Missing or contradictory organization signals increase or preserve restrictions; they never reduce them. +- A candidate with organization scope but without the organization review reason still requires tenant authority. +- A candidate with the organization review reason but a non-organization scope still requires tenant authority. +- Organization-sensitive evidence cannot bypass tenant authority merely because `requires_review` is false. +- A candidate with neither organization signal does not receive an organization-only prompt or blocker. +- A valid tenant attestation cannot replace exact candidate, review, destination, provider, account-scope, expiry, confirmation-phrase, or durable authorization binding. +- The frontend projection cannot mint, refresh, persist, or extend mutation authority. + +## Test-first evidence + +The TypeScript RED commit `76960c1db7707cfe402abd3d96409d3bf8baf0b6` introduced scope-only, reason-only, missing-attestation, and `requires_review = false` regressions before production changed. The Rust RED commit `1788a8e43cac3fea45daa05e6e6e8fde6e3841f8` exercised the public durable transfer gate for the same signal matrix. The production GREEN commit `be3a222a62685f22007eb097c0a86d7e4592cdb9` applies the disjunctive requirement in both frontend and Rust and blocks organization-sensitive candidates without an ordinary review flag. The follow-up `0f411d173643a3e8a727745599cb23bd9d020ef0` aligns an existing organization-scoped Naruon lineage fixture with the stricter attestation contract rather than weakening the gate. + +No predecessor-head CI, review, or approval evidence authorizes these commits. The unchanged exact head must independently pass repository Test and Release workflows, current security/SAST gates, exact production coverage, actionable review closure, branch/ruleset policy, and any qualifying independent approval required by live policy or explicit governance. + +## Rollback + +Rollback is a reviewed security-boundary change. Reverting only the disjunctive predicate or the no-ordinary-review blocker would knowingly restore the fail-open condition and is prohibited. A justified rollback must revert the production behavior, both regression suites, this decision record, and the matching changelog evidence together, and must introduce an independently reviewed replacement authorization contract that remains at least as restrictive. No database migration or persisted-schema rollback is involved. + +## Standalone and CWL integration boundary + +The tenant-authority gate is local to DiskSage's review and transfer authorization. It does not require Naruon, contextual-orchestrator, or a central CWL runtime to function. Naruon may consume bounded lineage or readiness evidence, but it cannot manufacture the tenant attestation or bypass DiskSage's exact Rust transfer checks. Central organization workflows may verify the implementation as repository evidence; they do not become runtime authorization. + +## References + +Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Rev. 5). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 + +National Institute of Standards and Technology. (2025, August 27). *NIST releases revision to SP 800-53 controls*. https://csrc.nist.gov/News/2025/nist-releases-revision-to-sp-800-53-controls + +MITRE. (2026). *CWE-863: Incorrect authorization* (CWE Version 4.20). https://cwe.mitre.org/data/definitions/863.html + +OWASP Foundation. (2025). *OWASP Application Security Verification Standard* (Version 5.0.0). https://owasp.org/www-project-application-security-verification-standard/ diff --git a/src-tauri/src/cloud_eviction.rs b/src-tauri/src/cloud_eviction.rs index aeb8468a1..b2fbabe06 100644 --- a/src-tauri/src/cloud_eviction.rs +++ b/src-tauri/src/cloud_eviction.rs @@ -880,7 +880,7 @@ mod tests { src: source.to_string_lossy().into_owned(), dst: destination.to_string_lossy().into_owned(), provider: CloudProvider::Onedrive, - destination_account_scope: crate::cloud::CloudAccountScope::Organization, + destination_account_scope: crate::cloud::CloudAccountScope::Personal, kind: ArchiveKind::Document, bytes: metadata.len(), age_days: 1, @@ -911,7 +911,7 @@ mod tests { let root = CloudRoot { id: cloud_dir.to_string_lossy().into_owned(), provider: CloudProvider::Onedrive, - account_scope: crate::cloud::CloudAccountScope::Organization, + account_scope: crate::cloud::CloudAccountScope::Personal, label: "test".into(), path: cloud_dir.to_string_lossy().into_owned(), readable: true, diff --git a/src-tauri/src/cloud_transfer.rs b/src-tauri/src/cloud_transfer.rs index d8a54ef1c..e5fc42665 100644 --- a/src-tauri/src/cloud_transfer.rs +++ b/src-tauri/src/cloud_transfer.rs @@ -409,7 +409,7 @@ fn candidate_blockers_for_action( let mut exact_review_approved = false; let organization_tenant_authority_required = candidate.destination_account_scope == CloudAccountScope::Organization - && candidate + || candidate .review_reasons .iter() .any(|reason| reason == ORGANIZATION_TENANT_AUTHORITY_REVIEW_REASON); @@ -451,6 +451,9 @@ fn candidate_blockers_for_action( Some(_) => exact_review_approved = true, } } + if organization_tenant_authority_required && !candidate.requires_review { + blockers.push("organization-tenant-authority-attestation-required".into()); + } let existing_destination_candidate = candidate.blocked_reason.as_deref() == Some("destination-exists"); if candidate.blocked_reason.is_some() @@ -1457,7 +1460,7 @@ mod tests { CloudRoot { id: "icloud:test".into(), provider: CloudProvider::Icloud, - account_scope: CloudAccountScope::Organization, + account_scope: CloudAccountScope::Personal, label: "iCloud Drive".into(), path: ROOT.into(), readable: true, @@ -1472,7 +1475,7 @@ mod tests { src: SOURCE.into(), dst: DESTINATION.into(), provider: CloudProvider::Icloud, - destination_account_scope: CloudAccountScope::Organization, + destination_account_scope: CloudAccountScope::Personal, kind: ArchiveKind::Document, bytes: 12, age_days: 90, @@ -1684,7 +1687,7 @@ mod tests { .contains(&"source-equals-destination".to_string())); let mut changed_scope = root(); - changed_scope.account_scope = CloudAccountScope::Personal; + changed_scope.account_scope = CloudAccountScope::Organization; assert!(candidate_blockers(&candidate(), &changed_scope) .contains(&"destination-account-scope-mismatch".to_string())); @@ -1900,7 +1903,7 @@ mod tests { CloudReviewDisposition::Approved, 11, "human:local:reviewer", - "Metadata title, account scope, and destination reviewed.", + "[organization-tenant-authority-confirmed] Metadata title, account scope, and destination reviewed.", ) .unwrap(); assert!(candidate_blockers_with_review(&reviewed, &root(), Some(&approved)).is_empty()); @@ -1929,7 +1932,7 @@ mod tests { ); assert_eq!( reviewed_lineage.review_rationale.as_deref(), - Some("Metadata title, account scope, and destination reviewed.") + Some("[organization-tenant-authority-confirmed] Metadata title, account scope, and destination reviewed.") ); let mut organization_sensitive = reviewed.clone(); @@ -2010,7 +2013,7 @@ mod tests { CloudReviewDisposition::Approved, 13, "human:local:reviewer", - "Filename date is auxiliary; destination and surrounding context were reviewed.", + "[organization-tenant-authority-confirmed] Filename date is auxiliary; destination and surrounding context were reviewed.", ) .unwrap(); assert!( @@ -2251,7 +2254,7 @@ mod tests { let test_root = CloudRoot { id: "icloud:test".into(), provider: CloudProvider::Icloud, - account_scope: CloudAccountScope::Organization, + account_scope: CloudAccountScope::Personal, label: "iCloud Drive".into(), path: cloud.to_string_lossy().into_owned(), readable: true, @@ -2327,7 +2330,7 @@ mod tests { let test_root = CloudRoot { id: "icloud:test".into(), provider: CloudProvider::Icloud, - account_scope: CloudAccountScope::Organization, + account_scope: CloudAccountScope::Personal, label: "iCloud Drive".into(), path: cloud.to_string_lossy().into_owned(), readable: true, @@ -2381,7 +2384,7 @@ mod tests { let test_root = CloudRoot { id: "icloud:test".into(), provider: CloudProvider::Icloud, - account_scope: CloudAccountScope::Organization, + account_scope: CloudAccountScope::Personal, label: "iCloud Drive".into(), path: cloud.to_string_lossy().into_owned(), readable: true, @@ -2427,7 +2430,7 @@ mod tests { let test_root = CloudRoot { id: "icloud:test".into(), provider: CloudProvider::Icloud, - account_scope: CloudAccountScope::Organization, + account_scope: CloudAccountScope::Personal, label: "iCloud Drive".into(), path: cloud.to_string_lossy().into_owned(), readable: true, @@ -2483,7 +2486,7 @@ mod tests { let test_root = CloudRoot { id: "icloud:test".into(), provider: CloudProvider::Icloud, - account_scope: CloudAccountScope::Organization, + account_scope: CloudAccountScope::Personal, label: "iCloud Drive".into(), path: cloud.to_string_lossy().into_owned(), readable: true, @@ -2527,7 +2530,7 @@ mod tests { let test_root = CloudRoot { id: "icloud:test".into(), provider: CloudProvider::Icloud, - account_scope: CloudAccountScope::Organization, + account_scope: CloudAccountScope::Personal, label: "iCloud Drive".into(), path: cloud.to_string_lossy().into_owned(), readable: true, @@ -2568,7 +2571,7 @@ mod tests { let test_root = CloudRoot { id: "icloud:test".into(), provider: CloudProvider::Icloud, - account_scope: CloudAccountScope::Organization, + account_scope: CloudAccountScope::Personal, label: "iCloud Drive".into(), path: cloud.to_string_lossy().into_owned(), readable: true, diff --git a/src-tauri/src/naruon_lineage.rs b/src-tauri/src/naruon_lineage.rs index 2ebbb0765..bef5ba30a 100644 --- a/src-tauri/src/naruon_lineage.rs +++ b/src-tauri/src/naruon_lineage.rs @@ -501,7 +501,7 @@ mod tests { CloudReviewDisposition::Approved, 25, "human:local:test", - "embedded metadata checked", + "[organization-tenant-authority-confirmed] embedded metadata checked", ) .unwrap(); let root = CloudRoot { diff --git a/src-tauri/tests/cloud_transfer_tenant_authority_gate.rs b/src-tauri/tests/cloud_transfer_tenant_authority_gate.rs new file mode 100644 index 000000000..846481faf --- /dev/null +++ b/src-tauri/tests/cloud_transfer_tenant_authority_gate.rs @@ -0,0 +1,215 @@ +//! Integration coverage for the durable organization-tenant authorization boundary. +//! +//! These tests exercise the public transfer gate rather than duplicating its predicate. They +//! prove that either canonical organization signal is sufficient to require an explicit human +//! tenant-authority attestation before a cloud copy can proceed. + +use disksage_lib::cloud::{ + candidate_review_fingerprint, ArchiveKind, CloudAccountScope, CloudCandidate, CloudProvider, + CloudRoot, MetadataEvidence, ORGANIZATION_TENANT_AUTHORITY_REVIEW_REASON, +}; +use disksage_lib::cloud_review::{ + create_attributed_decision, CloudReviewDisposition, ORGANIZATION_TENANT_AUTHORITY_ATTESTATION, +}; +use disksage_lib::cloud_transfer::candidate_blockers_with_review; + +#[cfg(windows)] +const CLOUD_ROOT_PATH: &str = r"C:\cloud"; +#[cfg(not(windows))] +const CLOUD_ROOT_PATH: &str = "/cloud"; +#[cfg(windows)] +const SOURCE_PATH: &str = r"C:\source\report.pdf"; +#[cfg(not(windows))] +const SOURCE_PATH: &str = "/source/report.pdf"; +#[cfg(windows)] +const DESTINATION_PATH: &str = r"C:\cloud\DiskSage Archive\report.pdf"; +#[cfg(not(windows))] +const DESTINATION_PATH: &str = "/cloud/DiskSage Archive/report.pdf"; + +/// Build a cloud root whose account scope exactly matches the candidate under test. +fn cloud_root(account_scope: CloudAccountScope) -> CloudRoot { + CloudRoot { + id: format!("icloud:{}", account_scope.as_str()), + provider: CloudProvider::Icloud, + account_scope, + label: "iCloud Drive".into(), + path: CLOUD_ROOT_PATH.into(), + readable: true, + access_issue: None, + } +} + +/// Build a realistic, otherwise eligible candidate with the requested organization signals. +fn candidate( + destination_account_scope: CloudAccountScope, + review_reasons: &[&str], + requires_review: bool, +) -> CloudCandidate { + let mut candidate = CloudCandidate { + metadata_fingerprint: "a".repeat(64), + review_fingerprint: String::new(), + src: SOURCE_PATH.into(), + dst: DESTINATION_PATH.into(), + provider: CloudProvider::Icloud, + destination_account_scope, + kind: ArchiveKind::Document, + bytes: 12, + age_days: 90, + created_ms: 1, + modified_ms: 2, + production_time_ms: 3, + production_time_source: "embedded:exiftool:CreateDate".into(), + production_time_confidence: "high".into(), + source_root: SOURCE_PATH.into(), + relative_path: "report.pdf".into(), + source_context: "source".into(), + requires_review, + review_reasons: review_reasons.iter().map(|reason| (*reason).into()).collect(), + content_title: Some("Report".into()), + content_authors: vec!["Author".into()], + content_context: vec!["Context".into()], + duration_ms: None, + dataset_profile: None, + metadata_evidence: vec![MetadataEvidence { + field: "production_time".into(), + value: "2026-01-01".into(), + source: "exiftool:CreateDate".into(), + confidence: "high".into(), + }], + blocked_reason: None, + }; + candidate.review_fingerprint = candidate_review_fingerprint(&candidate); + candidate +} + +/// Create an exact approved review decision that deliberately lacks tenant-authority attestation. +fn unconfirmed_decision(candidate: &CloudCandidate) -> disksage_lib::cloud_review::CloudReviewDecision { + create_attributed_decision( + candidate, + CloudReviewDisposition::Approved, + 100, + "human:integration-reviewer", + "Candidate metadata and destination were reviewed without organization tenant authority.", + ) + .expect("the realistic review decision should be valid") +} + +/// Create an exact approved decision with the canonical tenant-authority attestation marker. +fn confirmed_decision(candidate: &CloudCandidate) -> disksage_lib::cloud_review::CloudReviewDecision { + let rationale = format!( + "{ORGANIZATION_TENANT_AUTHORITY_ATTESTATION} Organization tenant authority was independently confirmed." + ); + create_attributed_decision( + candidate, + CloudReviewDisposition::Approved, + 100, + "human:integration-reviewer", + &rationale, + ) + .expect("the tenant-authority decision should be valid") +} + +#[test] +fn either_organization_signal_requires_explicit_tenant_authority_attestation() { + let cases = [ + ( + "organization scope only", + CloudAccountScope::Organization, + vec!["embedded-metadata-probe-incomplete"], + true, + ), + ( + "organization reason only", + CloudAccountScope::Personal, + vec![ORGANIZATION_TENANT_AUTHORITY_REVIEW_REASON], + true, + ), + ( + "both canonical signals", + CloudAccountScope::Organization, + vec![ORGANIZATION_TENANT_AUTHORITY_REVIEW_REASON], + true, + ), + ( + "shared scope with organization reason", + CloudAccountScope::Shared, + vec![ORGANIZATION_TENANT_AUTHORITY_REVIEW_REASON], + true, + ), + ( + "unknown scope with organization reason", + CloudAccountScope::Unknown, + vec![ORGANIZATION_TENANT_AUTHORITY_REVIEW_REASON], + true, + ), + ( + "neither organization signal", + CloudAccountScope::Personal, + vec!["embedded-metadata-probe-incomplete"], + false, + ), + ]; + + for (label, scope, reasons, tenant_authority_required) in cases { + let candidate = candidate(scope, &reasons, true); + let decision = unconfirmed_decision(&candidate); + let blockers = + candidate_blockers_with_review(&candidate, &cloud_root(scope), Some(&decision)); + let blocked = blockers + .iter() + .any(|blocker| blocker == "organization-tenant-authority-attestation-required"); + assert_eq!( + blocked, tenant_authority_required, + "{label} produced blockers: {blockers:?}" + ); + } +} + +#[test] +fn organization_scope_only_accepts_exact_tenant_authority_attestation() { + let candidate = candidate( + CloudAccountScope::Organization, + &["embedded-metadata-probe-incomplete"], + true, + ); + let decision = confirmed_decision(&candidate); + let blockers = candidate_blockers_with_review( + &candidate, + &cloud_root(CloudAccountScope::Organization), + Some(&decision), + ); + + assert!( + !blockers + .iter() + .any(|blocker| blocker == "organization-tenant-authority-attestation-required"), + "organization scope alone must not require an additional canonical review reason after exact attestation: {blockers:?}" + ); +} + +#[test] +fn organization_signals_require_tenant_authority_even_without_ordinary_review() { + let cases = [ + ( + "organization scope without ordinary review", + CloudAccountScope::Organization, + vec!["embedded-metadata-probe-incomplete"], + ), + ( + "organization reason without ordinary review", + CloudAccountScope::Personal, + vec![ORGANIZATION_TENANT_AUTHORITY_REVIEW_REASON], + ), + ]; + + for (label, scope, reasons) in cases { + let candidate = candidate(scope, &reasons, false); + let blockers = candidate_blockers_with_review(&candidate, &cloud_root(scope), None); + assert!( + blockers + .iter() + .any(|blocker| blocker == "organization-tenant-authority-attestation-required"), + "{label} must fail closed without tenant-authority attestation: {blockers:?}" + ); + } +} diff --git a/src/lib/cloudReviewQueue.authorization.test.ts b/src/lib/cloudReviewQueue.authorization.test.ts new file mode 100644 index 000000000..63787e7c2 --- /dev/null +++ b/src/lib/cloudReviewQueue.authorization.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import type { CloudCandidate, CloudReviewDecision } from "./api"; +import { + ORGANIZATION_TENANT_AUTHORITY_ATTESTATION, + cloudReviewQueueState, + organizationTenantAuthorityRequired, +} from "./cloudReviewQueue"; + +const ORGANIZATION_TENANT_AUTHORITY_REVIEW_REASON = + "organization-cloud-sensitive-context-needs-explicit-tenant-approval"; + +function candidate( + id: string, + overrides: Partial, +): CloudCandidate { + return { + metadata_fingerprint: id.repeat(64), + review_fingerprint: `${id}r`.repeat(32), + src: `/source/${id}.pdf`, + dst: `/cloud/${id}.pdf`, + provider: "icloud", + destination_account_scope: "unknown", + kind: "document", + bytes: 1_024, + age_days: 10, + created_ms: 100, + modified_ms: 200, + production_time_ms: 300, + production_time_source: "filesystem:created", + production_time_confidence: "low", + source_root: "/source", + relative_path: `${id}.pdf`, + source_context: ".", + requires_review: true, + review_reasons: ["destination-account-scope-unknown"], + content_title: null, + content_authors: [], + content_context: [], + duration_ms: null, + dataset_profile: null, + metadata_evidence: [], + blocked_reason: null, + ...overrides, + }; +} + +function approvedDecision( + item: CloudCandidate, + rationale = "metadata reviewed", +): CloudReviewDecision { + return { + version: 2, + decision_id: "d".repeat(64), + candidate_fingerprint: item.metadata_fingerprint, + review_fingerprint: item.review_fingerprint, + disposition: "approved", + reviewed_at_ms: 400, + reviewed_by: "human:local:test", + rationale, + }; +} + +describe("organization tenant authority fail-closed validation", () => { + it("requires tenant authority when either canonical organization signal is present", () => { + const organizationScopeOnly = candidate("a", { + destination_account_scope: "organization", + review_reasons: ["destination-account-scope-unknown"], + }); + const organizationReasonOnly = candidate("b", { + destination_account_scope: "personal", + review_reasons: [ORGANIZATION_TENANT_AUTHORITY_REVIEW_REASON], + }); + const ordinaryPersonal = candidate("c", { + destination_account_scope: "personal", + review_reasons: ["personal-cloud-sensitive-context-needs-explicit-approval"], + }); + + expect(organizationTenantAuthorityRequired(organizationScopeOnly)).toBe(true); + expect(organizationTenantAuthorityRequired(organizationReasonOnly)).toBe(true); + expect(organizationTenantAuthorityRequired(ordinaryPersonal)).toBe(false); + }); + + it("refuses approval when either organization signal is present without attestation", () => { + for (const item of [ + candidate("a", { + destination_account_scope: "organization", + review_reasons: ["destination-account-scope-unknown"], + }), + candidate("b", { + destination_account_scope: "personal", + review_reasons: [ORGANIZATION_TENANT_AUTHORITY_REVIEW_REASON], + }), + ]) { + expect(cloudReviewQueueState(item, [approvedDecision(item)])).toBe("unreviewed"); + expect(cloudReviewQueueState(item, [approvedDecision( + item, + `${ORGANIZATION_TENANT_AUTHORITY_ATTESTATION} Tenant and destination verified.`, + )])).toBe("approved"); + } + }); + + it("blocks organization signals when the ordinary review flag is absent", () => { + for (const item of [ + candidate("d", { + destination_account_scope: "organization", + requires_review: false, + review_reasons: ["embedded-metadata-probe-incomplete"], + }), + candidate("e", { + destination_account_scope: "personal", + requires_review: false, + review_reasons: [ORGANIZATION_TENANT_AUTHORITY_REVIEW_REASON], + }), + ]) { + expect(cloudReviewQueueState(item, [])).toBe("blocked"); + } + }); +}); diff --git a/src/lib/cloudReviewQueue.ts b/src/lib/cloudReviewQueue.ts index befa7aa14..3712cced8 100644 --- a/src/lib/cloudReviewQueue.ts +++ b/src/lib/cloudReviewQueue.ts @@ -44,13 +44,22 @@ export interface CloudReviewQueuePage { totalItems: number; } +/** + * Reports whether an approval needs explicit organization-tenant authority. + * + * The destination scope and the backend-authored review reason are independent + * safety signals. Either signal is sufficient to require the attestation so a + * missing or contradictory field cannot make an organization-sensitive + * candidate easier to approve. Only a candidate with neither signal uses the + * ordinary approval contract. + */ export function organizationTenantAuthorityRequired(candidate: CloudCandidate): boolean { return candidate.destination_account_scope === "organization" - && candidate.review_reasons.includes(ORGANIZATION_TENANT_AUTHORITY_REVIEW_REASON); + || candidate.review_reasons.includes(ORGANIZATION_TENANT_AUTHORITY_REVIEW_REASON); } function isReviewFormatControl(character: string): boolean { - const codepoint = character.codePointAt(0) ?? -1; + const codepoint = character.codePointAt(0)!; return codepoint === 0x00ad || (codepoint >= 0x0600 && codepoint <= 0x0605) || [0x061c, 0x06dd, 0x070f, 0x08e2, 0x180e, 0xfeff].includes(codepoint) @@ -163,6 +172,7 @@ export function cloudReviewQueueState( decisions: CloudReviewDecision[], ): CloudReviewQueueState { if (candidate.blocked_reason !== null) return "blocked"; + if (!candidate.requires_review && organizationTenantAuthorityRequired(candidate)) return "blocked"; if (!candidate.requires_review) return "ready"; return matchingReviewDecision(candidate, decisions)?.disposition ?? "unreviewed"; }