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
9 changes: 7 additions & 2 deletions docs/memory/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -843,9 +843,12 @@ The per-agent VoIP config + voice-picker UI lives in the agent Settings/Sharing
### Webhook Triggers (WEBHOOK-001)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/webhooks/{webhook_token}` | Token (URL-embedded) | Trigger schedule execution; rate-limited 10/60s per token via `rate_limiter.py` (#1023); returns 202 |
| POST | `/api/webhooks/{webhook_token}` | Token (URL-embedded) + optional HMAC | Trigger schedule execution; rate-limited 10/60s per token via `rate_limiter.py` (#1023); returns 202. When the schedule has signature auth on, an `X-Trinity-Signature: sha256=HMAC-SHA256(secret, raw_body)` header is required (fail-closed 401 on missing/invalid) (ent#77) |
| POST/DELETE | `/api/agents/{name}/schedules/{id}/webhook/secret` | JWT (`AuthorizedAgent`) | Enable/rotate signature auth — mints the signing secret, returns it **exactly once** (`whsec_…`, then only the AES-256-GCM envelope is kept) / disable auth, URL stays live (ent#77) |

Token lifecycle: `secrets.token_urlsafe(32)` stored in `agent_schedules.webhook_token` (partial unique index, O(1) lookup); re-POST rotates (old URL instantly invalid); DELETE nulls (subsequent triggers 404). Optional `{"context": "..."}` body (max 4000 chars) appended to the schedule message wrapped in a framing header to reduce prompt-injection surface. All triggers audit-logged with `triggered_by="webhook"`; auto-derives idempotency key `(token, body_hash)` (Invariant #18).
Token lifecycle: `secrets.token_urlsafe(32)` stored in `agent_schedules.webhook_token` (partial unique index, O(1) lookup); re-POST rotates (old URL instantly invalid) and clears any signing secret; DELETE nulls (subsequent triggers 404). Optional `{"context": "..."}` body (max 4000 chars) appended to the schedule message wrapped in a framing header to reduce prompt-injection surface. All triggers audit-logged with `triggered_by="webhook"`; auto-derives idempotency key `(token, body_hash)` (Invariant #18).

**Signature auth (ent#77):** optional per-schedule HMAC layer so a leaked URL alone can't trigger the schedule — off by default. `POST .../webhook/secret` mints a `whsec_` secret (returned once; stored only as an AES-256-GCM envelope, Invariant #12), sets `webhook_auth_enabled`. The public trigger verifies `X-Trinity-Signature = sha256=HMAC-SHA256(secret, raw_body)` (`services/webhook_signature.py`, constant-time) after the body is read + size-capped, **fail-closed** (401 on missing/invalid, 500 on an unreadable stored secret — never a silent bypass). Rotating the URL or revoking clears the secret. Mint/rotate/disable are `AuthorizedAgent` (aligns with schedule management). UI: the Schedules-tab per-schedule **Webhook** panel (enable/reveal/copy URL, example `curl`, rotate/revoke, enable/rotate/disable signing, secret shown once).

**Creation gate (#1445):** schedule *and* webhook creation require a **live owning agent** — `db.is_agent_live(name)` checks an `agent_ownership` row with `deleted_at IS NULL` (no `users` join, so it matches the token-lookup predicate exactly). A nonexistent / soft-deleted agent returns **404** (non-owners get a uniform **403** whether or not the agent exists — no enumeration oracle); enforced at both the router (`create_schedule`/`generate_webhook`) and the db chokepoint (`db/schedules.py:create_schedule` → `None`). This closes the orphan-schedule class (an admin's `can_user_access_agent` is unconditionally `True`, so admin callers could otherwise mint a schedule + real token on a never-created agent) so a webhook token always resolves to a schedule of a live agent — the invariant the #1423 token-lookup INNER JOIN assumes.

Expand Down Expand Up @@ -1168,6 +1171,8 @@ CREATE TABLE agent_schedules (
timeout_seconds INTEGER, -- #913: NULL = inherit agent cap
webhook_token TEXT, -- WEBHOOK-001: 43-char urlsafe token, nullable
webhook_enabled INTEGER DEFAULT 0, -- WEBHOOK-001
webhook_secret_encrypted TEXT, -- ent#77: AES-256-GCM HMAC signing secret (Invariant #12), nullable
webhook_auth_enabled INTEGER DEFAULT 0, -- ent#77: gate signature verification in the public trigger
deleted_at TEXT, -- #834: NULL = live; set = soft-deleted
FOREIGN KEY (owner_id) REFERENCES users(id)
);
Expand Down
33 changes: 33 additions & 0 deletions docs/user-docs/api-reference/webhook-triggers.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,41 @@ Expose a public URL that fires an agent schedule from an external system (CI/CD,
| `/api/agents/{name}/schedules/{id}/webhook` | POST | JWT | Generate (or rotate) a webhook token for a schedule |
| `/api/agents/{name}/schedules/{id}/webhook` | GET | JWT | Get the current token status + URL |
| `/api/agents/{name}/schedules/{id}/webhook` | DELETE | JWT | Revoke the token (old URL immediately 404s) |
| `/api/agents/{name}/schedules/{id}/webhook/secret` | POST | JWT | Enable / rotate **signature auth**; returns the signing secret **once** |
| `/api/agents/{name}/schedules/{id}/webhook/secret` | DELETE | JWT | Disable signature auth (URL stays live, unauthenticated) |
| `/api/webhooks/{token}` | POST | Token (in URL) | Public trigger — returns `202 Accepted`; optional `{"context": "..."}` body (≤4000 chars) is appended to the schedule message |

### Configuring a webhook from the UI

Open **Agent → Schedules**, expand a schedule, and click **Webhook**:

1. **Enable webhook** mints the URL. Use **Reveal** / **Copy URL** and the ready-to-paste **Example request** (`curl`) to wire up your caller.
2. **Rotate URL** issues a new token (the old URL 404s immediately); **Revoke** turns the webhook off entirely.

Access follows the schedule-management model — any user who can manage the agent's schedules can mint/rotate/revoke a webhook.

### Securing a webhook with a signature (recommended)

By default the URL token *is* the whole credential, so a leaked URL can trigger the schedule. Turn on **Signature authentication** to require callers to prove possession of a shared secret:

1. In the webhook panel, under **Signature authentication**, click **Enable**. Trinity shows the **signing secret exactly once** (`whsec_…`) — copy it now; it is stored only encrypted (AES-256-GCM) and never shown again.
2. Each request must include an `X-Trinity-Signature: sha256=<hex>` header, where `<hex>` is `HMAC-SHA256(secret, raw_request_body)`. Requests with a missing or invalid signature are rejected **401**. An empty body is signed as the empty string.
3. **Rotate secret** issues a new one (old signatures stop working); **Disable** removes it. Rotating the *URL* also clears the secret — re-enable signing afterward.

Example (bash):

```bash
SECRET='whsec_xxxxxxxx'
BODY='{"context":"deploy #4213 finished"}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)"
curl -X POST 'https://your-domain.com/api/webhooks/<token>' \
-H 'Content-Type: application/json' \
-H "X-Trinity-Signature: $SIG" \
-d "$BODY"
```

All webhook calls are audit-logged (caller IP, schedule, agent). Signature auth is off by default; enabling it never changes the URL.

**Creation precondition (#1445):** creating a schedule (`POST /api/agents/{name}/schedules`) and generating a webhook token both require the target agent to **exist and be live** (not deleted). Calling either on a nonexistent or deleted agent returns **404 Not Found**; callers without access to the agent get **403 Forbidden** regardless of whether the agent exists. This guarantees a webhook URL always points at a schedule of a live agent — you cannot mint a token that would later 404 at trigger time.

### Internal Execution (no auth -- internal network only)
Expand Down
8 changes: 8 additions & 0 deletions src/backend/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,14 @@ def revoke_webhook_token(self, schedule_id: str):
def get_webhook_status(self, schedule_id: str):
return self._schedule_ops.get_webhook_status(schedule_id)

def set_webhook_secret(self, schedule_id: str):
# ent#77: mint/rotate the HMAC signing secret; returns plaintext once.
return self._schedule_ops.set_webhook_secret(schedule_id)

def clear_webhook_secret(self, schedule_id: str):
# ent#77: disable signature auth + drop the stored secret.
return self._schedule_ops.clear_webhook_secret(schedule_id)

def set_schedule_enabled(self, schedule_id: str, enabled: bool):
return self._schedule_ops.set_schedule_enabled(schedule_id, enabled)

Expand Down
20 changes: 20 additions & 0 deletions src/backend/db/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -1913,6 +1913,25 @@ def _migrate_agent_schedules_webhook(cursor, conn):
conn.commit()


def _migrate_agent_schedules_webhook_auth(cursor, conn):
"""Add optional HMAC signature auth to schedule webhooks (trinity-enterprise#77).

webhook_secret_encrypted holds an AES-256-GCM envelope of the signing secret
(Invariant #12); webhook_auth_enabled gates verification in the public
trigger. Both default off, so an existing token-in-URL webhook is unchanged.
"""
_safe_add_column(
cursor, "agent_schedules", "webhook_secret_encrypted",
"ALTER TABLE agent_schedules ADD COLUMN webhook_secret_encrypted TEXT",
log_msg="Adding webhook_secret_encrypted to agent_schedules for webhook signature auth (ent#77)...",
)
_safe_add_column(
cursor, "agent_schedules", "webhook_auth_enabled",
"ALTER TABLE agent_schedules ADD COLUMN webhook_auth_enabled INTEGER DEFAULT 0",
)
conn.commit()


def _migrate_agent_shared_files(cursor, conn):
"""Create agent_shared_files table and add file_sharing_enabled to agent_ownership.

Expand Down Expand Up @@ -2756,6 +2775,7 @@ def _migrate_agent_reports_table(cursor, conn):
("sync_health", _migrate_sync_health),
("whatsapp_bindings", _migrate_whatsapp_bindings),
("agent_schedules_webhook", _migrate_agent_schedules_webhook),
("agent_schedules_webhook_auth", _migrate_agent_schedules_webhook_auth),
("agent_shared_files", _migrate_agent_shared_files),
("agent_sessions_tables", _migrate_agent_sessions_tables),
("session_compact_events", _migrate_session_compact_events),
Expand Down
102 changes: 98 additions & 4 deletions src/backend/db/schedules.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@ def _row_to_schedule(row) -> Schedule:
# Webhook trigger (WEBHOOK-001 / #647 follow-up)
webhook_enabled=bool(row["webhook_enabled"]) if "webhook_enabled" in row_keys and row["webhook_enabled"] is not None else False,
webhook_token=row["webhook_token"] if "webhook_token" in row_keys else None,
# ent#77: signature-auth fields (graceful default for pre-migration rows)
webhook_auth_enabled=bool(row["webhook_auth_enabled"]) if "webhook_auth_enabled" in row_keys and row["webhook_auth_enabled"] is not None else False,
webhook_secret_encrypted=row["webhook_secret_encrypted"] if "webhook_secret_encrypted" in row_keys else None,
)

@staticmethod
Expand Down Expand Up @@ -838,7 +841,16 @@ def generate_webhook_token(self, schedule_id: str) -> Optional[str]:
result = conn.execute(
update(agent_schedules)
.where(agent_schedules.c.id == schedule_id)
.values(webhook_token=token, webhook_enabled=1, updated_at=now)
# ent#77: rotating the URL is a credential-rotation event — reset
# any prior signing secret so a leaked old secret can't sign for
# the new token. The caller re-enables signing explicitly.
.values(
webhook_token=token,
webhook_enabled=1,
webhook_secret_encrypted=None,
webhook_auth_enabled=0,
updated_at=now,
)
)
if result.rowcount == 0:
return None
Expand Down Expand Up @@ -888,21 +900,100 @@ def set_webhook_enabled(self, schedule_id: str, enabled: bool) -> bool:
return result.rowcount > 0

def revoke_webhook_token(self, schedule_id: str) -> bool:
"""Revoke a webhook token, immediately invalidating the URL."""
"""Revoke a webhook token, immediately invalidating the URL.

ent#77: also clears the signing secret + auth flag — a revoked webhook
must not leave a live secret behind, and a re-minted token should start
auth-off (the caller re-enables signing explicitly).
"""
now = utc_now_iso()
with get_engine().begin() as conn:
result = conn.execute(
update(agent_schedules)
.where(agent_schedules.c.id == schedule_id)
.values(
webhook_token=None,
webhook_enabled=0,
webhook_secret_encrypted=None,
webhook_auth_enabled=0,
updated_at=now,
)
)
return result.rowcount > 0

# ---- ent#77: webhook signature-auth secret --------------------------------

@staticmethod
def _encrypt_webhook_secret(secret: str) -> str:
from services.credential_encryption import get_credential_encryption_service
from services.webhook_signature import SECRET_ENVELOPE_KEY
return get_credential_encryption_service().encrypt({SECRET_ENVELOPE_KEY: secret})

@staticmethod
def _decrypt_webhook_secret(encrypted: Optional[str]) -> Optional[str]:
if not encrypted:
return None
try:
from services.credential_encryption import get_credential_encryption_service
from services.webhook_signature import SECRET_ENVELOPE_KEY
return get_credential_encryption_service().decrypt(encrypted).get(SECRET_ENVELOPE_KEY)
except Exception as e:
logger.error(f"Failed to decrypt webhook secret: {e}")
return None

def set_webhook_secret(self, schedule_id: str) -> Optional[str]:
"""Mint (or rotate) the HMAC signing secret and enable signature auth.

Returns the PLAINTEXT secret exactly once (the caller surfaces it to the
user and never persists it in the clear); only the AES-256-GCM envelope
is stored. Returns None if the schedule row is gone. Requires an existing
webhook token — signature auth on a schedule with no webhook is a no-op,
so the router gates on `has_token` first.
"""
secret = "whsec_" + secrets.token_urlsafe(32)
encrypted = self._encrypt_webhook_secret(secret)
now = utc_now_iso()
with get_engine().begin() as conn:
result = conn.execute(
update(agent_schedules)
.where(
and_(
agent_schedules.c.id == schedule_id,
agent_schedules.c.webhook_token.isnot(None),
)
)
.values(
webhook_secret_encrypted=encrypted,
webhook_auth_enabled=1,
updated_at=now,
)
)
if result.rowcount == 0:
return None
return secret

def clear_webhook_secret(self, schedule_id: str) -> bool:
"""Disable signature auth and drop the stored secret (webhook stays live)."""
now = utc_now_iso()
with get_engine().begin() as conn:
result = conn.execute(
update(agent_schedules)
.where(agent_schedules.c.id == schedule_id)
.values(webhook_token=None, webhook_enabled=0, updated_at=now)
.values(
webhook_secret_encrypted=None,
webhook_auth_enabled=0,
updated_at=now,
)
)
return result.rowcount > 0

def get_webhook_status(self, schedule_id: str) -> Optional[Dict]:
"""Return webhook configuration for a schedule."""
"""Return webhook configuration for a schedule (never the secret)."""
stmt = select(
agent_schedules.c.webhook_token,
agent_schedules.c.webhook_enabled,
agent_schedules.c.webhook_auth_enabled,
agent_schedules.c.webhook_secret_encrypted,
).where(
and_(
agent_schedules.c.id == schedule_id,
Expand All @@ -917,6 +1008,9 @@ def get_webhook_status(self, schedule_id: str) -> Optional[Dict]:
"webhook_token": row["webhook_token"],
"webhook_enabled": bool(row["webhook_enabled"]),
"has_token": row["webhook_token"] is not None,
# ent#77 — surface the auth STATE only, never the secret material
"auth_enabled": bool(row["webhook_auth_enabled"]),
"has_secret": row["webhook_secret_encrypted"] is not None,
}

# =========================================================================
Expand Down
6 changes: 6 additions & 0 deletions src/backend/db/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@
validation_timeout_seconds INTEGER DEFAULT 120,
webhook_token TEXT,
webhook_enabled INTEGER DEFAULT 0,
-- trinity-enterprise#77: optional HMAC signature auth on the public
-- webhook. webhook_secret_encrypted is an AES-256-GCM envelope
-- (Invariant #12); webhook_auth_enabled gates verification. Both
-- default off — a plain token-in-URL webhook is unchanged.
webhook_secret_encrypted TEXT,
webhook_auth_enabled INTEGER DEFAULT 0,
deleted_at TEXT,
FOREIGN KEY (owner_id) REFERENCES users(id)
)
Expand Down
2 changes: 2 additions & 0 deletions src/backend/db/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ def process_bind_param(self, value, dialect):
Column("validation_timeout_seconds", Integer),
Column("webhook_token", Text),
Column("webhook_enabled", Integer),
Column("webhook_secret_encrypted", Text), # ent#77: AES-256-GCM HMAC secret
Column("webhook_auth_enabled", Integer), # ent#77: gate signature verify
Column("deleted_at", Text),
)

Expand Down
7 changes: 7 additions & 0 deletions src/backend/db_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,13 @@ class Schedule(BaseModel):
# carried these fields — every webhook trigger raised AttributeError.
webhook_enabled: bool = False
webhook_token: Optional[str] = None
# trinity-enterprise#77: optional HMAC signature auth. `webhook_auth_enabled`
# gates verification in the public trigger; `webhook_secret_encrypted` is the
# AES-256-GCM envelope the trigger decrypts to verify the signature. Neither
# is ever surfaced in an API response model (the plaintext secret is returned
# exactly once, at mint time, and never persisted in the clear).
webhook_auth_enabled: bool = False
webhook_secret_encrypted: Optional[str] = None


class ScheduleExecution(BaseModel):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Add webhook signature-auth columns to agent_schedules (trinity-enterprise#77)

Optional HMAC-SHA256 signature auth on the public schedule webhook. Mirrors the
SQLite ``agent_schedules_webhook_auth`` migration in ``db/migrations.py`` and the
DDL in ``db/schema.py`` / MetaData in ``db/tables.py``.

- ``webhook_secret_encrypted`` — AES-256-GCM envelope of the signing secret
(Invariant #12); never stored in plaintext.
- ``webhook_auth_enabled`` — gates verification in the public trigger; default 0
so an existing token-in-URL webhook is unchanged.

Fresh PG builds already get the columns because ``0001_baseline`` iterates
``db/schema.py:TABLES``. This revision exists so an *existing* PG deployment —
stamped at an earlier revision and never re-running baseline — also picks the
columns up on ``alembic upgrade head``.

Revision ID: 0014_agent_schedules_webhook_auth
Revises: 0013_public_chat_messages_sender
Create Date: 2026-07-06
"""
from alembic import op

# revision identifiers, used by Alembic.
revision = "0014_agent_schedules_webhook_auth"
down_revision = "0013_public_chat_messages_sender"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.execute(
"ALTER TABLE agent_schedules ADD COLUMN IF NOT EXISTS webhook_secret_encrypted TEXT"
)
op.execute(
"ALTER TABLE agent_schedules ADD COLUMN IF NOT EXISTS webhook_auth_enabled INTEGER DEFAULT 0"
)


def downgrade() -> None:
op.execute(
"ALTER TABLE agent_schedules DROP COLUMN IF EXISTS webhook_auth_enabled"
)
op.execute(
"ALTER TABLE agent_schedules DROP COLUMN IF EXISTS webhook_secret_encrypted"
)
Loading
Loading