App access
Declare who may use an application and keep entry and discovery decisions consistent.
See Who may do what for the access model.
Declare access in defineApp() to choose who may use your application.
Cloud evaluates this rule before application code and uses it to filter
application discovery. Resource permissions still decide what a caller may do
inside the application.
Choose a fixed rule
import { appAccess, defineApp } from "@k2b/cloud";
export const app = defineApp({
id: "inventory",
name: "Inventory",
icon: "ti ti-packages",
description: "Track stock and warehouse movements.",
baseUrl: "http://app-inventory:3000",
routes: ["/app/inventory", "/api/inventory"],
access: appAccess.signedIn(),
});| Rule | Who may use the app |
|---|---|
appAccess.signedIn() |
Any resolved signed-in identity, including service accounts |
appAccess.noGuests() |
Signed-in identities except people with a guest profile, including credentials delegated by a guest |
appAccess.roles(["admin"]) |
Identities whose effective user has one of the listed Cloud roles |
appAccess.resources({ adapters }) |
Accounts with an effective resource right in at least one selected adapter |
appAccess.grants() |
Identities with read or admin on the application's access list |
roles() accepts Cloud roles, not application-defined roles. Use a group grant
for an application-specific audience. A standalone service account has no user
roles; it can use signedIn, noGuests, or an explicit grant. A Cloud app
session never regains the Cloud administrator role through this check.
Spaces, Grids, Contacts, Mail, Pulse, and Venue use noGuests(). Guests and
credentials delegated by them cannot use these apps through pages, API, CLI,
capabilities, widgets, or search, even with an object grant. Public-route
exemptions and independent resource-bound keys retain their contracts.
Operators should check affected guests before upgrading.
Credentials retain their scopes and resource binding. App access opens the application; it does not widen a key or replace route and resource checks.
Derive entry from existing resource permissions
Use appAccess.resources() when the application's existing resource rights
should also decide who sees and enters it. Select one or more existing
ResourceAccessAdapters by resource type. Cloud admits an account when at
least one adapter reports an effective read, write, or admin right.
No app-access list is created or synchronized. Resource checks inside the app
continue to authorize each operation.
Each selected adapter must implement hasAnyRight(subject, context). The
method is optional on existing adapters, but required for this declaration.
A missing method fails application startup. Cloud cannot derive a correct
existence query from list(resourceId) without enumerating resources, so it
provides no scanning fallback.
Add the query to the adapter that already owns the rights. This example uses
an application's existing inventory.workspace_access junction and its
inventory.workspaces table with UUID resource IDs. Substitute the tables,
ID type and eligibility predicates
used by your resource service:
import { appAccess, type ResourceAppAccessAdapter } from "@k2b/cloud";
import {
buildAccessPrincipalCondition,
type ResourceAccessAdapter,
} from "@k2b/cloud/server";
import { workspaceAccess } from "./service/access";
const workspace = {
...workspaceAccess,
async hasAnyRight(subject, { authority, db, signal }) {
signal.throwIfAborted();
const bound = authority.actor.kind === "service_account"
&& authority.actor.serviceAccount.kind === "resource_bound"
? authority.actor.serviceAccount.resourceId : null;
const principal = buildAccessPrincipalCondition({
subject,
columns: {
userId: db`a.user_id`,
groupId: db`a.group_id`,
serviceAccountId: db`a.service_account_id`,
authenticatedOnly: db`a.authenticated_only`,
},
});
const query = db<{ allowed: boolean }[]>`
SELECT EXISTS (
SELECT 1 FROM inventory.workspace_access r
JOIN inventory.workspaces w ON w.id = r.workspace_id
JOIN auth.access a ON a.id = r.access_id
WHERE NOT w.archived
AND a.permission IN ('read', 'write', 'admin')
AND ${principal}
AND (${bound}::uuid IS NULL OR w.id = ${bound}::uuid)
) AS allowed
`;
const cancel = () => query.cancel();
signal.addEventListener("abort", cancel, { once: true });
try {
signal.throwIfAborted();
return (await query)[0]?.allowed === true;
} finally {
signal.removeEventListener("abort", cancel);
}
},
} satisfies ResourceAccessAdapter & ResourceAppAccessAdapter;
// Pass this to defineApp({ ..., access }). Keys are the resourceType values
// used by this app's resource-bound service accounts.
export const access = appAccess.resources({
adapters: { workspace },
});Use an indexed existence query and the same effective-right semantics as the resource service, including inherited rights, domain eligibility and nested groups. A positive grant on an archived or otherwise inaccessible resource must not admit the caller. For a bound service account, inspect only its bound resource ID. Cloud also restricts it to its app and the matching adapter key; standalone and agent accounts use their own grants. Delegated credentials use their resolved access subject. Guests receive no implicit right and can enter only when the resource service grants them a right. Credential scopes remain in force.
Queries run in the owning application. Discovery reaches them through Core
with a dedicated signed token restricted to an app-access existence check;
reusable credentials go only to Core. Core receives exactly the credential
admitted by the source route, including an app session's administrator ceiling.
Invocation and probe tokens cannot be exchanged for discovery probes by other
apps. Local entry checks still accept authorized invocations. Cross-app
discovery through Core requires an OAuth token with the existing cloud
audience; an app-specific OAuth audience does not become Core authority.
A background authority without the originating request credential can query
local adapters but cannot discover resource-derived apps in other processes.
Run cross-app discovery through Core with an admitted credential; an
invocation handler cannot exchange its token for a broader app catalog. An expired credential denies access. Discovery runs at most four resource
probes at once, each with a fresh three-second limit under a six-second overall
deadline. An unavailable or timed-out app is omitted only from that request;
other apps retain their results. A supplied caller signal replaces the overall
default deadline; the caller must bound it. Discovery retains the three-second
per-app limit under that caller deadline.
Universal Search also shares its eight-second signal with invocation signing
and provider requests. Registry reads and grants SQL are not cancelled by
that signal.
Entry into one application allows up to eight seconds for its resource check,
below the server's ten-second idle timeout and the live-revalidation check
limit. Browser entry and subsequent SSR discovery share the page request's
eight-second resource deadline. Long-running API handlers receive a fresh
budget for each later access check. A confirmed denial returns 403; a failed or
timed-out resource check returns 503 with code: "app_access_unavailable"
and a retry message. canUseApp throws AppAccessUnavailableError for that
unavailable check; usableAppIds omits the affected app. An aborted caller
signal preserves its cancellation reason. Probes are not retried. The adapter
must observe signal and cancel outstanding work when it aborts.
Cloud reuses an identical decision only within the originating request. There is no cache between requests: removing the last right or group membership removes visibility and entry on the next request. Existing live connections close within the existing one-minute revalidation window. Public-route exemptions keep their own authentication as described below.
Preserve public routes
Declare routes that have their own public or specialized authentication:
access: appAccess.signedIn({
publicRoutes: ["/inventory/shared/:token", "/api/inventory/webhook"],
}),These paths bypass only the app entry rule. The application's route middleware still validates share tokens, webhook signatures, or other credentials. Do not exempt an entire API prefix to accommodate one public endpoint.
Framework assets and readiness remain available independently of app access. Framework endpoints that contain app information or execute operations also participate in the access decision.
Use a configurable list
appAccess.grants() declares a platform-evaluated list, not a callback to
application code. read means Use; admin means Manage access and also
includes use. Installation administrators do not receive app use automatically.
An authenticated grant means All signed-in people, including guests.
It matches a user access subject, including a person's delegated credentials,
invocations and mandates. Agents and other service accounts need a direct or
group grant. Fixed rules such as appAccess.signedIn() retain their existing
behavior; this change applies to grant rows. See the
upgrade inventory.
When moving from a fixed rule to a list, declare the previous rule as until:
access: appAccess.grants({ until: appAccess.signedIn() }),Until the list is first saved, the previous rule remains effective. Once saved,
only the list applies. Without until, an unconfigured list grants nobody
access. Do not substitute signedIn() for a previously narrower rule.
The snapshot's configured field becomes true on the first successful list
edit. Reading the list does not configure it. A failed edit leaves the previous
rule in effect. For a signedIn() predecessor, the editor initially shows an
inherited signed-in Use entry; the first save preserves it as a real grant.
Remove that entry deliberately when narrowing use. Other fixed predecessors
remain effective until first save but do not create a signed-in grant.
If no manager exists yet, an installation administrator must assign the first manager through explicit repair. The predecessor permits use, not management. All built-in applications keep fixed rules in the first rollout; the shared editor does not make them configurable automatically.
Restrict who may manage the list
Users, groups, and service accounts may receive Use (read) or Manage
access (admin). The signed-in audience may receive only Use. Public grants
and a separate Write level are not supported for app access.
An optional canManage(authority, { db }) callback adds an application-owned
restriction to a Manage access grant. For example, an app can limit ordinary
management to managers whose effective user is a Cloud administrator:
import { appAccess } from "@k2b/cloud";
import { userFromActor } from "@k2b/cloud/server";
export const access = appAccess.grants({
until: appAccess.signedIn(),
canManage: (authority) =>
userFromActor(authority.actor)?.roles.includes("admin") === true,
});Pass access to defineApp(). Returning true never substitutes for a
management grant or the credential's write authority. The callback runs in
the owning application for both list reads and edits; it is not part of the
serializable app-use rule and cannot grant use. Core repair is a separate,
explicit administration path and does not run this callback.
SQL-backed restrictions must use the callback's db argument and pass it to
any domain service they call. During an edit it is the connection holding the
app-list transaction. Opening another connection from the global pool can
block concurrent edits; a callback that only checks the resolved authority
may omit the second argument.
Add the editor to application settings
Import AppAccessEditor from @k2b/cloud/access/ui inside a Solid island:
// Access.island.tsx
import {
AppAccessEditor,
type AppAccessEditorProps,
} from "@k2b/cloud/access/ui";
export default function Access(props: AppAccessEditorProps) {
return <AppAccessEditor {...props} />;
}Render it from the application's settings page with app={app.meta}. For
SSR, load the authorized initial snapshot with readAppAccess from
@k2b/cloud/services and pass it as initial:
import { auth } from "@k2b/cloud/server";
import { AppAccessManagementError, canManageAppAccess, readAppAccess } from "@k2b/cloud/services";
import { access, app } from "../config";
// Inside the settings page's async SSR handler, with request context c:
const authority = auth.getAuthority(c);
let showAccess = await canManageAppAccess(authority, app.meta, access.canManage);
const initial = showAccess
? await readAppAccess({ app: app.meta, authority, canManage: access.canManage }).catch((error: unknown) => {
if (!(error instanceof AppAccessManagementError && error.status === 403)) throw error;
showAccess = false;
return undefined;
})
: undefined;In the page's returned render function, use
<Show when={showAccess}><Access app={app.meta} initial={initial} /></Show>
and show the Access tab only when showAccess is true. The read passes the declaration's
canManage callback explicitly because app.meta contains only the
serializable rule. Apply the page's authenticated route
policy. The example handles a permission change between these two reads as
loss of management and renders the remaining settings without the Access tab.
Unexpected read failures reach the page's error handling and need a visible
error and retry.
See SSR pages and routing.
The component accepts:
| Prop | Behavior |
|---|---|
app |
Required { id, name, access? }; fixed or undeclared rules render explanatory text instead of an editable list |
initial |
Authorized AppAccessSnapshot; without it a grants editor reads the list after browser mount |
onAccessLost |
Called when an edit removes app use or a follow-up read loses management; defaults to reloading the current page |
repair |
Selects explicit Core repair; reserved for an administration surface and still authorized by the server |
The snapshot includes entries, matching ownEntryIds (including group
grants), configured, hasEffectiveManager, inheritedSignedIn, and canUse, alongside appId and
name. Group entries include groupMemberCount (distinct people reached through
nested membership). Entries also include effectiveManager for platform
eligibility: expired people, empty groups and disabled accounts do not count.
These presentation fields do not replace the server check or an app-owned
canManage restriction. Use the snapshot for authorized initial rendering. The editor uses the inherited
UI locale and reads the canonical list
after changes. Keep its default reload behavior or make onAccessLost return
to a page that rechecks authorization.
A failed initial read shows Reload. A missing or changed grant requires Reload before another edit; loss of management shows a terminal message. Other failed edits offer a retry. If a write response is lost, the editor reads the list to check whether the change committed before allowing another write. If that read also fails, editing stays blocked until Reload succeeds. Custom clients must also read the current list before retrying an uncertain write.
Use the shared management route
The editor and app access CLI commands
use /_cloud/app-access/:appId. For ordinary management, Core returns a
same-origin HTTP 307 redirect for GET and POST to the owning application's
framework route. The redirect preserves the method and POST body. For
routes: ["/app/inventory", "/api/inventory"], that route is
/app/inventory/_cloud/access/inventory. Declare a concrete first route
prefix; the framework mounts this endpoint before the app-use gate and checks
management authority independently. Do not mount a second management API or
add the endpoint to publicRoutes.
Only ?repair=1 on the Core route selects administrator repair. Ordinary
management never falls back to repair after a denial. Repair grants no app
use: the administrator needs an applicable app grant to use a configured app.
Administrators can open Administration → Apps, the app, and Access (the app shows Repair access when nobody manages it) even when
the app is absent from their launcher. Fixed rules cannot be repaired into
lists; changing a fixed rule requires an application declaration update.
At /admin/apps, selecting an app shows its state, traffic, access, keys, events and
notifications as one column of rows; each row opens its own column, and the address keeps
the selection (/admin/apps?app=mail&view=access). Access shows the rule or the access
editor for configurable access. Administrators can read the same detail with
cld apps show <app-id>. Newly observed subscriptions are marked for seven
days. See Inspect readiness and failures
for offline apps, list limits and delivery recovery.
Save without losing the last manager
Each list edit serializes changes for the app and commits the grant, the
first-configuration marker, audit records, and the change notification outbox
entry in one transaction. Audit actions are app.access.granted,
app.access.updated, and app.access.revoked; administrator repair records
via: "platform-admin". Audit or outbox failure rolls the edit back.
Every edit must leave an effective manager. Empty groups, expired people,
disabled service accounts, and user-delegated or resource-bound keys cannot
serve as the last manager. A group with an unexpired member, including nested
membership, or an active standalone or agent account can. A violating edit
returns HTTP 409 with code: "LAST_MANAGER"; add another manager first. This
check also applies during repair and first configuration.
With a canManage restriction, Cloud also needs proof that a remaining
manager passes it: the current caller with its unchanged authority, an active
web or mobile session, or an active standalone or agent API credential. The
check reloads identity and preserves credential limits. It examines at most
100 stored session and credential candidates; an unproved result rejects the
edit. An offline manager or an OAuth-only manager might therefore need to sign
in before another manager can leave. Keep the callback deterministic and free
of side effects; do not rely on request-specific provenance for this check.
Core repair cannot run the app's callback and applies the platform eligibility
check instead. Later changes to groups, accounts or credentials can still
require administrator repair.
The editor confirms removal or downgrade of an entry applying to the caller. Cancellation keeps the entry; confirmation still passes through the server's last-manager check. Losing Use returns the caller to normal page authorization.
Understand denial and discovery
Without app access, an application does not appear in the launcher, rail, search, dashboard, Help, Skills, widget selection, Assistant tools, or CLI plugin list. A direct link may name the application and whom to contact; it must not show application content or offer a request-access action.
API and CLI entry attempts return HTTP 403 with code: "app_access_denied",
the application ID, and a recovery message. Cross-app calls check access both
before issuing an invocation and when the target receives it. Permission
lookup errors deny access.
Changes take effect on the next request. Open live connections must revalidate when access changes and at least once per minute, including current account, role, group, and credential state. Failed revalidation closes the connection.
Check access inside a service
Use canUseApp(authority, app) or usableAppIds(authority, apps) from
@k2b/cloud/services when a service needs the same app-entry decision as the
platform. Pass auth.getAuthority(c) or appAccessAuthority(c) so credential
limits and the request transport to Core remain available. Continue to check
the operation's resource permissions separately.
Inside a SQL transaction, pass its connection as the optional third argument:
canUseApp(authority, app, { db }) or usableAppIds(authority, apps, { db }).
This keeps list grants and local adapter reads in the transaction and avoids
waiting for a second pooled connection. A remote adapter runs in its owning
application and reads committed rights there; it cannot share the caller's SQL
transaction. The caller retains its transaction connection and locks during
the bounded network check (up to eight seconds for entry).
Keep this cost in mind when calling the resolver from a transaction; moving
authorization outside a guarded write would introduce a separate race.
Outside a transaction, omit the option. Framework-managed
invocation issuance carries its transaction through identity and access checks
automatically.
Upgrade an existing application
In this release, an application without access keeps its existing entry and
discovery behavior and logs a startup warning. Declare a rule deliberately:
nav.requiresRoles was a visibility filter and may not describe every caller
that already uses the API. Inventory public routes and test service accounts,
delegated credentials, guests, and mobile sessions before shipping the rule.
See Route policies and Resource authorization for the checks that continue inside the app.