Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1542,3 +1542,9 @@ The disclosure input parses a form's body as JSON, as it does for a JSON body.
**Date**: 2026-10-27
**Decision**: `GET /health` returns 503 `status: degraded` when its bounded `SELECT 1` fails, and 200 `status: ok` otherwise. `/ready` keeps the same gate. The Cloud Run startup and liveness probes on the API are `tcp_socket` probes on the container port, not HTTP probes on `/health`. The GCM uptime check behind `[P0] API Down` and the Better Stack monitor stay on `/health`, so a database outage pages in prod. Dev's nightly Cloud SQL stop therefore fires dev's `API Down` every night, and that is accepted: it emails only, and it proves the path end to end. `routes/health.rs`, `infra/modules/cloud-run/main.tf`.
**Rationale**: `/health` was always-200 so that a Cloud SQL blip would not make the probes restart containers. That never worked. The probe's default 1 s timeout is shorter than `/health`'s 2 s database timeout, so with the database gone the liveness probe failed anyway. Dev's instance was shut down about 90 s after every nightly stop, and a replacement cannot boot without the database (`create_app` connects and migrates eagerly), so Cloud Run answered 500 until morning. The design gave neither a surviving container nor an honest status. Splitting the two questions fixes both. Whether the process is up is a TCP connect, which nothing upstream can fail. Whether the API can serve is `/health`, which the monitors read and which no longer restarts anything. A TCP startup probe gives up nothing, because the listener binds only after `create_app` has connected, migrated and preflighted. No new endpoint was added, since a TCP probe needs none.

## D-NEXT: "Allow & Remember" expires after an org-set default, 7 days out of the box; an omitted `ttl` means that default, not forever

**Date**: 2026-10-28
**Decision**: Orgs have a `default_remember_ttl_secs` setting (migration 139). It is nullable, NULL means forever, it is bounded to 60 s to 365 d, and the column default is 604800 (7 days). The column default also backfills every existing org. Admins set it on `PATCH /v1/orgs/{id}/execution-settings`, and Org Settings shows it as a select below the call timeouts. When `POST /v1/approvals/{id}/resolve` gets `allow_remember` with no `ttl`, the rule expires `now + default`. A permanent rule now needs an explicit `ttl: "forever"`, which `/resolve` accepts (it used to 400 on it). The dashboard's Allow split button still starts on Once (#844), tags the org default "· default" in its duration menu, and always sends `ttl`. The MCP remember dialog preselects the org default; if that default is not one of the dialog's menu values, it is added as a `"{n}s"` option, and the dialog forwards `forever` verbatim. The SDK approval card gets an "Org default" choice, selected by default, that leaves `ttl` out. One parser, `overslash_core::types::duration::parse_rule_ttl`, serves `/resolve` and `PATCH /v1/permissions/{id}`.
**Rationale**: Before this, a remembered rule was permanent unless the reviewer changed the picker. "Never" was the preselection, and leaving the field out meant forever. So the cheapest action created the longest-lived grant. The org default had to be applied on the server, when `ttl` is missing, rather than only preselected in the dashboard, because API, MCP and SDK resolvers never see the dashboard. This changes behaviour for API callers that relied on omitting `ttl` to get forever. That trade is deliberate: any caller that wants a permanent grant can still ask for one, and now has to ask explicitly. Backfilling existing orgs instead of grandfathering them to forever is also deliberate, because the safer default should reach the orgs that never chose. Rules already written keep the expiry they were written with.
13 changes: 11 additions & 2 deletions SCHEMA.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
-- PostgreSQL database dump
--

\restrict IU7gGYzzNTd60ELJcMnX68xk6MwIMaSwIk56fgOPGg3Phe56vVK1sb2CM7pgn1c
\restrict Ima6egLcgrnJ79TnxwblysVaIC9DjWdCHPDn0FnJXFFtcuWvCqfvYirq9Tx4HXW

-- Dumped from database version 16.14 (Debian 16.14-1.pgdg12+1)
-- Dumped by pg_dump version 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1)
Expand Down Expand Up @@ -1064,9 +1064,11 @@ CREATE TABLE public.orgs (
call_timeout_ms integer,
max_call_timeout_ms integer,
default_agent_self_setup boolean DEFAULT true NOT NULL,
default_remember_ttl_secs integer DEFAULT 604800,
CONSTRAINT orgs_approval_auto_bubble_secs_check CHECK ((approval_auto_bubble_secs >= 0)),
CONSTRAINT orgs_audit_response_body_mode_check CHECK ((audit_response_body_mode = ANY (ARRAY['off'::text, 'errors_only'::text, 'all'::text]))),
CONSTRAINT orgs_call_timeout_bounds CHECK ((((call_timeout_ms IS NULL) OR ((call_timeout_ms >= 1000) AND (call_timeout_ms <= 600000))) AND ((max_call_timeout_ms IS NULL) OR ((max_call_timeout_ms >= 1000) AND (max_call_timeout_ms <= 600000))) AND ((call_timeout_ms IS NULL) OR (max_call_timeout_ms IS NULL) OR (call_timeout_ms <= max_call_timeout_ms)))),
CONSTRAINT orgs_default_remember_ttl_bounds CHECK (((default_remember_ttl_secs IS NULL) OR ((default_remember_ttl_secs >= 60) AND (default_remember_ttl_secs <= 31536000)))),
CONSTRAINT orgs_plan_check CHECK ((plan = ANY (ARRAY['standard'::text, 'free_unlimited'::text, 'trial'::text]))),
CONSTRAINT orgs_user_template_policy_check CHECK ((user_template_policy = ANY (ARRAY['none'::text, 'restrictive'::text, 'full'::text])))
);
Expand Down Expand Up @@ -1107,6 +1109,13 @@ COMMENT ON COLUMN public.orgs.max_call_timeout_ms IS 'Ceiling on any resolved ca
COMMENT ON COLUMN public.orgs.default_agent_self_setup IS 'When true (default), a newly-created first-level agent (depth = 1) is seeded with the four overslash:*_own self-setup permission rules: manage_services_own, manage_templates_own, manage_connections_own, request_secrets_own. Existing agents are not touched when this flips.';


--
-- Name: COLUMN orgs.default_remember_ttl_secs; Type: COMMENT; Schema: public; Owner: -
--

COMMENT ON COLUMN public.orgs.default_remember_ttl_secs IS 'Default expiry in seconds for rules written by Allow & Remember when the resolver does not pass a ttl. NULL = forever.';


--
-- Name: pending_checkouts; Type: TABLE; Schema: public; Owner: -
--
Expand Down Expand Up @@ -4046,5 +4055,5 @@ ALTER TABLE ONLY public.webhook_subscriptions
-- PostgreSQL database dump complete
--

\unrestrict IU7gGYzzNTd60ELJcMnX68xk6MwIMaSwIk56fgOPGg3Phe56vVK1sb2CM7pgn1c
\unrestrict Ima6egLcgrnJ79TnxwblysVaIC9DjWdCHPDn0FnJXFFtcuWvCqfvYirq9Tx4HXW

4 changes: 3 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ The flow above expanded as discrete steps:
2. **Group check (Layer 1)**: is the service + access level within the owner-user's group grants? If not → **deny** (not approvable)
3. **Auto-approve bypass**: if the matching grant's `auto_approve_level` permits this action's risk, skip Layer 2 and call immediately (a deny rule still applies to mutating calls)
4. **Permission key check (Layer 2)**: are all derived keys covered by existing rules for this identity? If yes → **auto-approve**
5. If not → **create approval request** → user decides → "Allow & Remember" stores keys with optional TTL
5. If not → **create approval request** → user decides → "Allow & Remember" stores keys with a TTL (the org default — 7 days unless changed — or `forever` when asked for explicitly)

### Hierarchical Resolution

Expand Down Expand Up @@ -609,6 +609,8 @@ Approval and secret requests are **not notified immediately**. Only requests tha

"Allow & Remember" on an approval creates permission key rules with optional TTL. These rules auto-approve matching future requests. Permission rules and remembered approvals are the same concept — "permission rules" is the storage format, "remembered approvals" is the user-facing term. Users can view and revoke them per identity via the dashboard.

**Expiry.** The resolver may pass `ttl` (`1h`, `7d`, … up to 365 days, or `forever`). An omitted `ttl` takes the org's `default_remember_ttl_secs` (`PATCH /v1/orgs/{id}/execution-settings`; NULL = forever; **default 7 days**). The MCP remember dialog preselects that default; the dashboard's Allow menu tags it (the button itself starts on Once). Changing it never touches rules already written.

The rule is stored **only after a successful `POST /v1/approvals/{id}/call`** — a cancelled, expired, or failed replay leaves no rule behind. This prevents a reviewer from being silently committed to auto-approving an action they never saw succeed.

### Replay Semantics
Expand Down
9 changes: 3 additions & 6 deletions UI_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ A button with two halves joined seamlessly — left side is the default action,

**Structure**: left half has rounded left corners, right half (▾) has rounded right corners. No gap between them — they share a continuous border.

**Approval variant**: Left = runs the selected duration, labelled "Allow once" / "Allow for 24 hours" / "Allow always". Right dropdown = duration menu: **Once** (plain allow), then an "Allow & remember" section with **For 1 hour / 24 hours / 7 days / 30 days** and **Always** (remember at the scope chosen in the action bar's "Remember as" dropdown, with that expiry). Picking a duration only changes the selection; the left half resolves. Defaults to Once.
**Approval variant**: Left = runs the selected duration, labelled "Allow once" / "Allow for 24 hours" / "Allow always". Right dropdown = duration menu: **Once** (plain allow), then an "Allow & remember" section with **For 1 hour / 24 hours / 7 days / 30 days** and **Always** (remember at the scope chosen in the action bar's "Remember as" dropdown, with that expiry). Picking a duration only changes the selection; the left half resolves. Defaults to Once. The org's default remember expiry (`default_remember_ttl_secs`) is tagged "· default" in the menu, and gets its own "For …" entry when it is not one of the listed durations. Every remember choice sends `ttl` explicitly, so "Always" is `forever`, not an omission.

### Notifications Dropdown

Expand Down Expand Up @@ -657,9 +657,6 @@ User-level preferences. Changes take effect immediately.
- **Format**: Relative ("2m ago") / Absolute local / Absolute UTC
- **Timezone**: auto-detected from browser, overridable

**Approvals:**
- **Default approval TTL**: pre-filled expiry when clicking "Allow & Remember" (1h, 24h, 7d, 30d, never)

**Notifications:**
- **Notification preferences**: how to receive approval requests and secret requests — email, webhook URL, or dashboard-only

Expand Down Expand Up @@ -859,7 +856,6 @@ Features
Allow user-created templates [✓] Users can create personal service templates
Allow user-created services [✓] Users can create personal service instances
Show API Explorer [✓] API Explorer visible in nav for all users
Default approval TTL [24h ▾] Pre-filled expiry for "Allow & Remember"
```

Each with a toggle or dropdown. Settings configured via environment variables are shown as read-only with an "env" badge.
Expand Down Expand Up @@ -900,8 +896,9 @@ One card, admin-only, holding the org policies applied to an agent **at creation
- **New agents can set up their own services** (`default_agent_self_setup`, default **on**) — a first-level agent is seeded with the four `overslash:*_own` self-setup rules. The help text names what it does *not* include (the `_share`/`_publish` half) and warns that an agent owned by an org admin inherits admin reach through its owner's groups regardless.
- **Deferred execution by default for new agents** (`default_deferred_execution`, default off).
- **Upstream call timeouts** — default and maximum, blank to inherit the deployment value.
- **Default "Allow & Remember" expiry** (`default_remember_ttl_secs`, default **7 days**; Never / 1h / 24h / 7d / 30d). Unlike the rows above it is not a creation-time default: it applies to every remember resolve from the moment it changes. It is what an API or MCP resolve that omits `ttl` gets, and the approval's Allow menu tags that duration "· default" (the split button itself still starts on Once). Sits below the timeouts behind a divider so the card's "applied at creation time" description doesn't read as covering it.

All three patch the same `PATCH /v1/orgs/{id}/execution-settings` endpoint, one key at a time.
All of these patch the same `PATCH /v1/orgs/{id}/execution-settings` endpoint, one key at a time.

**Backfill control.** Because the self-setup default applies at agent-creation time only, an org that predates it has agents the policy never reached. A row under that toggle says how many (`agents_missing_self_setup`, carried on the same settings response) and offers a button labelled *Grant to N agents*, which `POST`s to `/v1/orgs/{id}/agent-self-setup/backfill` behind a confirm dialog. It is **disabled while the toggle is off**, with a tooltip saying to turn the default on first — granting against a declined policy would hand out what the org just opted out of. When nothing is pending the row reads "Every first-level agent in this org already has these rules" and offers no button. There is no bulk undo; the confirm dialog says so, and revocation stays per agent in the Permission Rules table.

Expand Down
25 changes: 15 additions & 10 deletions crates/overslash-api/src/routes/approvals/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,18 +234,23 @@ pub(super) async fn resolve_approval(
let mut parsed_expires_at: Option<time::OffsetDateTime> = None;
let mut remember_keys_to_store: Option<Vec<String>> = None;
if remember {
if let Some(t) = req.ttl.as_deref() {
let dur = overslash_core::types::duration::parse_ttl(t)
.ok_or_else(|| AppError::BadRequest(format!("invalid ttl: {t}")))?;
if dur.as_secs() > 365 * 86400 {
return Err(AppError::BadRequest("ttl must not exceed 365 days".into()));
// An explicit `ttl` wins (`"forever"` = no expiry); an omitted one
// falls back to the org's default, which starts at 7 days.
let ttl_secs: Option<i64> = match req.ttl.as_deref() {
Some(t) => overslash_core::types::duration::parse_rule_ttl(t)
.map_err(|e| AppError::BadRequest(e.to_string()))?
.map(|d| d.as_secs() as i64),
None => {
overslash_db::repos::org::get_default_remember_ttl_secs(state.db(&ext), auth.org_id)
.await?
.ok_or_else(|| AppError::NotFound("org not found".into()))?
.map(i64::from)
}
let secs: i64 = dur
.as_secs()
.try_into()
.map_err(|_| AppError::BadRequest("ttl value too large".into()))?;
};
if let Some(secs) = ttl_secs {
// `parse_rule_ttl` and the column CHECK both cap this at 365 days.
parsed_expires_at =
time::OffsetDateTime::now_utc().checked_add(time::Duration::new(secs, 0));
time::OffsetDateTime::now_utc().checked_add(time::Duration::seconds(secs));
}
let approval = &approval_pre;

Expand Down
Loading
Loading