Skip to content

feat: revalidate device permissions against OS state before use - #471

Open
TarikGul wants to merge 19 commits into
mainfrom
tg/os-permission-revalidation
Open

feat: revalidate device permissions against OS state before use#471
TarikGul wants to merge 19 commits into
mainfrom
tg/os-permission-revalidation

Conversation

@TarikGul

@TarikGul TarikGul commented Aug 21, 2026

Copy link
Copy Markdown
Member

Device permissions carry two gates, not one. The product decision is persisted once and never expires. The OS grant behind it belongs to the host application and moves on its own: the user can revoke it in system settings, device policy can suspend it, and the platform can reset it, as Android does for apps that go unused.

A device capability is usable only while both gates are open. That holds for a product calling request_device_permission and for a host settings screen reading through CoreAdmin, so the two cannot disagree. A screen reporting Authorized for something a request reports as granted: false sends the user after a product toggle that was never what blocked them.

The seam

PermissionStatusHost is an optional platform capability with one method, device_permission_status, which reports OS state and must not prompt. It stays separate from Permissions::device_permission because that call also puts the product's question to the user, so it cannot re-check a decision the user has already made.

It sits on OptionalPlatform, so codegen emits it as an optional group: permissionStatus? on the JS host callback surface. A host that omits it resolves device grants from stored state alone, which is what a host with no OS permission model does anyway.

The rule

Only an OS refusal overrides the stored decision.

  • An OS refusal denies without prompting, since only system settings can reach it. The stored product decision stays in place for when the user restores the OS grant.
  • An undetermined OS status changes nothing. The OS raises its own dialog when the capability is used, and the core cannot reach that dialog without re-asking a question RFC 0002 step 3 treats as settled.
  • A failed status query falls back to the stored decision. A dropped IPC is transient, and reading it as a refusal would let a flaky channel revoke a working capability.

Remote, identity disclosure and account access decisions have no OS gate and are untouched. Every product scoped permission service is built through one helper on ProductRuntimeHost, so the request and read paths cannot drift apart.

Hosts

Native. HostCallbacks carries device_permission_status, and CallbackPlatform serves the capability. The adapter travels on ConnectionAdapters rather than the host runtime, because a native host builds one CallbackPlatform per product execution, and the object reporting OS state has to be the one presenting the prompt.

The Swift protocol extension and the Kotlin interface both default the new member, so an embedding app compiles unchanged and opts in by implementing one method. Answer it from AVCaptureDevice.authorizationStatus(for:), UNUserNotificationCenter.getNotificationSettings, or ContextCompat.checkSelfPermission.

The callback returns NativeDevicePermissionStatus, a same namespace mirror of DevicePermissionStatus with a total conversion. UniFFI lowers a cross namespace type in an async callback return into the wrong RustBuffer, and the generated Kotlin then fails to compile.

WASM. The adapter installs when the JS side supplies devicePermissionStatus, including through the Web Worker, whose init message reports the capability so the worker proxies the callback.

CLI. Reports NotApplicable for every capability, since a terminal has no OS permission gate.

Known limitation

granted: false covers both a product refusal and an OS refusal, so neither a product nor a settings screen can tell "you declined this" from "the OS revoked this, open system settings". The remedies differ. Separating them needs a richer device permission response than the current boolean, so it waits for a breaking change window.

Android auto reset surfaces as an undetermined status, and re-requesting is the host's call, because a permission protected Android call throws rather than prompting.

Verification

Eighteen tests. Fourteen cover the decision matrix in host_logic::permissions. Three drive a real permissions_request_device_permission frame and a CoreAdmin status read through the generated dispatcher. One drives the native chain end to end: foreign callback, platform adapter, connection adapters, permission service.

Every test was confirmed to fail under a deliberate mutation, including dropping the OS refusal override, re-prompting on an undetermined status, mapping a query failure to a refusal, clearing the stored grant on refusal, dropping the read path gate, an installer that stores nothing, and an execution that stops passing its adapter. The integration and native tests are the only cover for the wiring, since the unit tests construct the service directly and survive every plumbing mutation.

Clean under RUSTFLAGS="-D warnings": cargo build --workspace --all-targets --all-features, cargo check --target wasm32-unknown-unknown -p truapi-server, cargo +nightly fmt --check, cargo +nightly clippy --workspace --all-targets --all-features -- -D warnings, and cargo test --workspace --all-features. sync-bindings.sh --check and make provider-swift-check both report the committed bindings current against a freshly generated tree.

Both native gates were compiled rather than reviewed: xcodebuild build-for-testing -scheme TrUAPIHost against the simulator, which is what compiles the hand written conformers, and make android-check.

make android-check also needed NativeCustomRendererObserver.onError implemented in the Kotlin conformer. That is unrelated to permissions and carried in its own commit. No CI job covers Android, so the gate had drifted.

Part of #334.

@TarikGul
TarikGul requested review from a team August 21, 2026 21:11
…ment

The doc comment on HostDevicePermissionRequest reaches the truapi
namespace bindings, so the committed Swift carries it too.

@Imod7 Imod7 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In js/packages/truapi-host/src/web/create-worker-host-runtime.ts, line 833:

          capabilities: { chat: host.chat !== undefined },

This never reports permissionStatus, so the worker does not proxy devicePermissionStatus and no revalidation happens in the browser. I tested it: the init message carries {"chat":false} even when the host serves the capability. Please add permissionStatus: host.permissionStatus !== undefined. The two toEqual assertions on capabilities in worker-provider.test.ts then need the new key, and makeHostCallbacks in test-support.ts needs a permissionStatus branch like the chat one.

Comment thread rust/crates/truapi-server/src/host_logic/permissions.rs Outdated
Comment thread rust/crates/truapi-server/src/host_logic/permissions.rs Outdated

@pgherveou pgherveou left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approving to unblock, will give a more torough review later this evening

Comment thread rust/crates/truapi-platform/README.md
Comment thread rust/crates/truapi-host-cli/src/platform.rs Outdated
Comment thread rust/crates/truapi-platform/src/lib.rs
An undetermined OS status no longer triggers a prompt. The prompt callback
answers the product's question as well as the OS one, so using it to reach
the OS dialog re-asked an answered question on every request and persisted
the OS answer over the product's decision. An OS dialog declined that way
became a permanent product denial that restoring the capability in system
settings could not recover, and a transient prompt failure on the same path
reported a held grant as denied.

The OS resolves its own gate when the capability is used, so the core
defers to the stored decision unless the OS refuses outright.

Also reports permissionStatus in the Web Worker init message, without which
the worker proxies no status callback and nothing revalidates in a browser.
@pgherveou

Copy link
Copy Markdown
Collaborator

A terminal has no OS permission gate, so the CLI reports NotApplicable for
every capability and the stored product decision governs.
A host settings screen read through permission_authorization_status answered
from stored state alone, so it reported Authorized for a capability a request
reported as granted: false, sending the user after a product toggle that was
never what blocked them. peek_device now resolves the same two gates.

Every product-scoped permission service is built through one helper on
ProductRuntimeHost so the request and read paths cannot drift again. Remote,
identity-disclosure and account-access decisions have no OS gate and are
unaffected.
Nine read-path doc comments promised a stored-only answer, plus RFC-0002
step 4 and three READMEs that described the gate on requests only. The Swift
bindings carry the regenerated wrapper docs.

Also folds the duplicated status-host installer doc into a link to the trait
that carries the reasoning, builds the wasm capability adapters through one
helper instead of repeating the wiring in both constructors, names the CLI
binding after what it holds, and asserts the whole response payload in the
integration test rather than splicing bytes by index.
The capability builder sat between the doc comment and the struct, which
fails missing_docs on the wasm target.
HostCallbacks gains device_permission_status next to device_permission, and
CallbackPlatform serves the PermissionStatusHost capability.

The adapter travels on ConnectionAdapters rather than the host runtime,
because a native host builds one CallbackPlatform per product execution: the
object that reports OS state has to be the one presenting the prompt, and a
set-once host-level slot cannot express that.

The Swift protocol extension defaults the new member, so an embedding app
compiles unchanged and opts in by implementing one method.
NativeCustomRendererObserver gained on_error and the Kotlin conformer never
implemented it, so make android-check failed before this branch. Closing the
flow with an error mirrors the Swift host, keeping a declined render distinct
from a clean end.

Unrelated to the permission work; fixed here because it blocked verifying the
Kotlin half of it, and no CI job covers Android.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants