These pages describe PrivacyFence 4.6.1. Download it · What changed
Organization deployment¶
This guide sets up PrivacyFence as one central Linux service that many people in an organization use. Everyone signs in with the organization's identity provider, connects their own accounts, and approves their own AI requests in the browser. It is written for the IT administrator who runs the server.
For a single person on their own computer, use the desktop install instead (getting started).
Contents¶
- Overview
- Prerequisites
- Service account and install
- Identity provider
- Connector apps
- Signing key and bundle
- Reverse proxy and TLS
- Hardened systemd unit
- First sign-in and validation
- Install-wide and per-user policy
- AI systems
- Approvals and step-up
- File delivery
- Operations
- Troubleshooting
1. Overview¶
An organization deployment has five parts:
| Part | What it is |
|---|---|
| The daemon | privacyfence-app, installed from PyPI into a virtual environment and run by systemd as a dedicated, unprivileged account. It listens on 127.0.0.1:8765. |
| The bundle | org_config.json: the organization's settings (server URL, identity provider, connector apps, step-up, downloads, audit forwarding), built and signed with build_org_bundle.py. |
| A reverse proxy | Caddy or nginx on the same host. It terminates HTTPS on your public hostname and forwards to the daemon. |
| Your identity provider | Any OpenID Connect (OIDC) provider your staff already sign in to. It decides who a person is. |
| Connector apps | One OAuth app per service you offer (Google, Slack, Salesforce, Atlassian). Each person authorizes their own account against it. No credentials are shared between people. |
What people see, all at https://<your-hostname>:
| Page | Purpose |
|---|---|
/login |
Sign in through the identity provider. |
/connect |
Connect or reconnect Gmail, Drive, Calendar, Contacts, Tasks, Apps Script, Slack, Salesforce, Jira, Confluence and Telegram. |
/approvals |
Pending AI requests to approve or deny. |
/security |
Enroll and remove passkeys. |
/settings |
Each person's own always-allow rules and recent audit entries; for admins also the install-wide privacy policy and the AI systems page. |
/mcp |
The MCP endpoint AI clients connect to. It uses OAuth 2.1 with dynamic client registration. |
This is the only way claude.ai can use PrivacyFence. A desktop install listens on localhost,
which claude.ai's servers cannot reach. Claude Desktop, Claude Code and other MCP-compatible clients
can use either kind of install. Section 9 shows how to add each
client.
2. Prerequisites¶
- A Linux server. Ubuntu 24.04 is the reference: it ships Python 3.12, and you add
python3-venv. Any other Linux with Python 3.11 or newer, thevenvmodule and systemd works the same way. Do not install the PrivacyFence.debon this host. The.debis the desktop package: it creates its ownprivacyfenceaccount and/var/lib/privacyfencelayout, and the daemon installed here would pick that layout up. - A public hostname (for example
pf.acme.example.com) with DNS pointing at the server, and a TLS certificate for it. Caddy obtains one automatically if ports 80 and 443 are reachable. The hostname must be reachable from the internet if people will use claude.ai, because claude.ai connects from Anthropic's servers, not from the person's browser. - Outbound HTTPS from the server to your identity provider and to the APIs of every connector you offer.
- Admin access to your identity provider, to register one OIDC client.
- Admin access to each connector's developer console for the services you want to offer.
build_org_bundle.py, which is not part of the PyPI package. Download it from the GitHub Release that matches the PrivacyFence version you install (it is attached to every stable release), or takescripts/build_org_bundle.pyfrom the repository at that version's tag. It needs only the Python standard library, plus thecryptographypackage for signing.
3. Service account and install¶
Create a dedicated system account. Its home directory holds all PrivacyFence data:
sudo apt install python3-venv
sudo useradd --system --home-dir /var/lib/privacyfence-org --create-home \
--shell /usr/sbin/nologin privacyfence-org
Install PrivacyFence into a virtual environment owned by root, so the service cannot change its own code:
sudo python3 -m venv /opt/privacyfence/venv
sudo /opt/privacyfence/venv/bin/pip install privacyfence
To install a specific version, use pip install privacyfence==X.Y.Z. PyPI carries stable releases
only.
The daemon keeps its data in ~/.privacyfence/ of the account it runs as, here
/var/lib/privacyfence-org/.privacyfence/. This guide calls that directory the data directory.
It creates every directory there with mode 0700 and refuses to start in organization mode if the
data directory, its org/ directory, or its users/ tree grants any access to group or others.
Create the org/ directory now, as the service account, so it has the right owner before you copy
the bundle in:
sudo -u privacyfence-org install -d -m 700 /var/lib/privacyfence-org/.privacyfence/org
The layout of the data directory is in section 14.
4. Identity provider¶
Register one OIDC client for PrivacyFence with your identity provider (for example a "Web application" client in Google Cloud, an app registration in Microsoft Entra ID, or a client in Okta or Keycloak). Note its issuer URL, client ID and client secret.
Register these three redirect URIs on it, with your own hostname:
| Redirect URI | Used when |
|---|---|
https://pf.acme.example.com/oauth/idp/callback |
An AI client signs a person in through /mcp. |
https://pf.acme.example.com/oauth/idp/login-callback |
A person signs in to the web pages at /login. |
https://pf.acme.example.com/oauth/stepup/callback |
A person confirms an approval by signing in again, when step-up allows that (section 12). |
Requirements the daemon checks:
- The provider publishes OIDC discovery at
<issuer>/.well-known/openid-configuration, withissuer,authorization_endpoint,token_endpointandjwks_uri. The daemon fetches it at startup, so the provider must be reachable then. - The issuer and every endpoint in the discovery document use HTTPS. The daemon refuses plain HTTP.
- ID tokens are signed with
RS256orES256and carry asubclaim. - Sign-in requests the scopes
openid email profile.
Each person's data lives under users/<sub>/ in the data directory, keyed by the provider's sub
claim. A sub that contains characters other than letters, digits, ., _, @ and - (or is
longer than 200 characters) is stored under a stable hash instead (idp-<hash>).
Who is an admin¶
Admins see the General, Privacy Filter and AI systems pages in Settings. Nobody is an
admin unless you name a claim and its values with --idp-admin-group-claim and
--idp-admin-group-value (section 6). A person is an admin when the
claim contains any of the values. A claim that is a single string is matched like a one-item list.
If Google is your identity provider, use email as the admin claim. Google ID tokens carry no
group membership and no Workspace admin role, so groups never matches anyone. List each admin's
exact address:
--idp-admin-group-claim email \
--idp-admin-group-value [email protected] \
--idp-admin-group-value [email protected] \
Do not use hd (the Workspace domain): every account in the domain has the same value, so every
account would be an admin.
Admin status is decided at sign-in and kept for the browser session. After you change the admin list and restart the daemon, admins sign out and back in to pick it up.
Who may sign in¶
By default, everyone your identity provider authenticates may use PrivacyFence. To narrow that, add either or both of these to the bundle:
--authz-allowed-domain acme.example.com(repeatable): only email addresses at these domains.--authz-groups-claim groups --authz-required-group privacyfence-users(value repeatable): only people whose claim contains one of the values. This is independent of the admin claim.
A person who fails either check is refused, and no session is created.
5. Connector apps¶
Each connector needs its own OAuth app registered as a web app with an HTTPS redirect URI on your hostname. The daemon builds the redirect URI from your server URL and the service name, and the connector's console must list it exactly.
| Service | Redirect URI to register | Bundle flags | Setup guide |
|---|---|---|---|
| Gmail | https://pf.acme.example.com/oauth/callback/gmail |
--google-client-secret |
Google Cloud setup |
| Google Drive | https://pf.acme.example.com/oauth/callback/drive |
(same Google client) | Google Cloud setup |
| Google Calendar | https://pf.acme.example.com/oauth/callback/calendar |
(same Google client) | Google Cloud setup |
| Google Contacts | https://pf.acme.example.com/oauth/callback/contacts |
(same Google client) | Google Cloud setup |
| Google Tasks | https://pf.acme.example.com/oauth/callback/tasks |
(same Google client) | Google Cloud setup |
| Google Apps Script | https://pf.acme.example.com/oauth/callback/apps_script |
(same Google client) | Google Cloud setup |
| Slack | https://pf.acme.example.com/oauth/callback/slack |
--slack-client-id, --slack-client-secret |
Slack setup |
| Salesforce | https://pf.acme.example.com/oauth/callback/salesforce |
--salesforce-consumer-key, --salesforce-consumer-secret, --salesforce-login-url |
Salesforce setup |
| Jira and Confluence | https://pf.acme.example.com/oauth/callback/atlassian |
--atlassian-client-id, --atlassian-client-secret |
Atlassian setup |
| Telegram | none | none | Telegram setup |
Notes:
- Google: one OAuth client of type Web application covers all six Google services, but it
must list all six redirect URIs, one per service you offer. Download its JSON file and pass it to
--google-client-secret. A Desktop app client cannot be used here, because it has no field for these redirect URIs. - Use a separate Google client for the connectors, not the one you registered in
section 4 for sign-in. A single client works, but Google then reports the
sign-in scopes (
openid,userinfo.email,userinfo.profile) in every connector's granted scopes, which makes audit and revocation harder to read. - Jira and Confluence share one Atlassian app and one redirect URI, because an Atlassian OAuth app accepts only one callback URL.
- Telegram needs nothing from you: its app credentials ship inside PrivacyFence. Each person
signs in with their phone number on
/connect. - A connector appears on
/connectonly if its section is in the bundle.
The same app registrations can also carry the loopback redirect URIs used by desktop installs; the setup guides cover both.
6. Signing key and bundle¶
The daemon refuses to start in organization mode unless the bundle is signed. The first signed bundle a server sees pins its signing key (trust on first use). Every later bundle must verify against that key.
Generate the signing key¶
Run this once per organization on an admin workstation, not on the server. It needs the
cryptography package:
python3 -m venv ~/pf-bundle && ~/pf-bundle/bin/pip install cryptography
~/pf-bundle/bin/python build_org_bundle.py --generate-signing-key ~/secure/org-signing-key.pem
The private key is written with mode 0600 and its public key is printed. Keep the file secret and
backed up: every future bundle, including --merge runs, must be signed with it.
Build the bundle¶
~/pf-bundle/bin/python build_org_bundle.py \
--org-name "Acme Corp" \
--mode org \
--server-issuer-url https://pf.acme.example.com \
--server-trusted-proxy 127.0.0.1 \
--idp-issuer https://idp.acme.example.com \
--idp-client-id <client id from section 4> \
--idp-client-secret <client secret from section 4> \
--idp-admin-group-claim groups --idp-admin-group-value privacyfence-admins \
--google-client-secret /path/to/client_secret.json \
--slack-client-id ... --slack-client-secret ... \
--step-up-enabled \
--sign-key ~/secure/org-signing-key.pem \
-o org_config.json
The script writes org_config.json with mode 0600 and prints what it contains.
| Option | Default | What it sets |
|---|---|---|
--org-name NAME |
none | The organization name. |
-o, --output PATH |
org_config.json |
Where to write the bundle. |
--merge |
off | Update the existing bundle at the output path instead of starting over. See below. |
--mode org |
none | Required. Without it the bundle describes a desktop install. |
--server-issuer-url URL |
none (required) | The public origin people and AI clients use, for example https://pf.acme.example.com. Every redirect URI and link is built from it. |
--server-bind-host HOST |
127.0.0.1 |
The address the daemon listens on. Change it only when the proxy runs on another host. |
--server-port PORT |
8765 |
The port the daemon listens on. |
--server-tls-cert PATH, --server-tls-key PATH |
none | Let the daemon terminate TLS itself. Leave both unset when the proxy terminates TLS. |
--server-trusted-proxy IP |
none | A proxy address whose X-Forwarded-For/X-Forwarded-Proto headers are trusted (repeatable). See section 7. |
--idp-issuer URL, --idp-client-id ID, --idp-client-secret SECRET |
none (required) | The OIDC client from section 4. |
--idp-admin-group-claim CLAIM, --idp-admin-group-value VALUE |
nobody is an admin | Who is an admin (section 4). The value is repeatable. |
--authz-allowed-domain DOMAIN |
anyone | Email domains allowed to sign in (repeatable). |
--authz-groups-claim CLAIM, --authz-required-group VALUE |
anyone | A claim and the values required to sign in (value repeatable). |
--google-client-secret PATH |
none | The Google web client's JSON file (section 5). |
--slack-client-id, --slack-client-secret |
none | The Slack app. --slack-scopes overrides the default user-token scopes; normally leave it unset. |
--salesforce-consumer-key, --salesforce-consumer-secret |
none | The Salesforce Connected App. |
--salesforce-login-url URL |
https://login.salesforce.com |
Use https://test.salesforce.com for sandboxes. |
--atlassian-client-id, --atlassian-client-secret |
none | The Atlassian app for Jira and Confluence. |
--step-up-enabled / --step-up-disabled |
off | Require a fresh passkey or sign-in before an approval is released (section 12). |
--step-up-scope SCOPE |
writes_and_pii_reads |
Which approvals need step-up: writes, writes_and_pii_reads or writes_and_reads. |
--step-up-require-passkey / --step-up-no-require-passkey |
off | Accept only a passkey for step-up, never a fresh sign-in. |
--step-up-rp-id DOMAIN |
the hostname of --server-issuer-url |
The WebAuthn relying-party ID. Set it only to use a parent domain. |
--step-up-rp-name NAME |
PrivacyFence |
The name shown in the operating system's passkey prompt. |
--idp-step-up-acr-value ACR |
none | acr_values sent when step-up asks the identity provider for a fresh sign-in (repeatable). Without it, the request uses prompt=login and max_age=0 only. |
--enable-unattended-sessions / --disable-unattended-sessions |
off | Let an AI client declare a scheduled, unattended run (privacyfence_begin_unattended_session). The declaration is advisory and grants nothing by itself; see how it works. |
--downloads-inline-max-bytes BYTES |
8000000 |
Files up to this size are returned inside the tool result. 0 sends every file through a download link (section 13). |
--downloads-link-ttl-seconds SECONDS |
300 |
How long a download link stays valid. |
--downloads-disable-staging |
staging on | Refuse a file too large to return inline instead of staging it on disk. |
--agent-links / --no-agent-links |
agent links on | Whether the AI client can fetch a download link itself, or only a signed-in person in a browser can. |
--enable-audit-forwarding / --disable-audit-forwarding |
off | Send audit entries to syslog or an HTTPS endpoint (section 14). |
--audit-forwarding-kind syslog\|http |
syslog |
The forwarding target. |
--audit-forwarding-syslog-host HOST |
none | The syslog server (required for syslog). |
--audit-forwarding-syslog-port PORT |
6514 |
The syslog port. |
--audit-forwarding-syslog-protocol udp\|tcp |
tcp |
The syslog transport. PrivacyFence sends plain syslog; it does not add TLS. |
--audit-forwarding-http-url URL |
none | An https:// endpoint that receives one JSON object per entry (required for http). |
--audit-forwarding-http-bearer-token-env NAME |
none | The name of an environment variable the daemon reads a bearer token from. The token itself never goes in the bundle; set the variable in the systemd unit (section 8). |
--generate-signing-key PATH |
Create a signing key and exit. | |
--sign-key PATH |
none (required with --mode org) |
Sign the bundle. |
python3 build_org_bundle.py --help lists the same options. The daemon also reads a few bundle
keys that the script does not set, and each person's policy lives in settings.yaml; the
configuration reference lists every key.
Step-up scope and the default. If you never pass --step-up-scope, the bundle carries no scope
and the daemon uses its default, writes_and_pii_reads. If you pass a value, it is written into the
bundle and stays exactly that. To keep step-up to writes only, pass --step-up-scope writes
explicitly. enabled and require_passkey work differently: absent from the bundle, both are off.
Changing a bundle later¶
--merge reads the existing bundle at the output path, applies only the flags you pass, and signs
it again. Always pass the same --sign-key.
Two sections are rebuilt as a whole rather than merged: --mode org rebuilds server and idp
from that run's flags. So to change anything about the server or the identity provider (for
example the admin list, or a rotated client secret), pass --mode org, --server-issuer-url, every
--server-* flag you use (a missing --server-bind-host goes back to 127.0.0.1) and every
--idp-* flag again. Connector, step-up, download, authorization and forwarding flags merge into
their existing sections.
Install the bundle¶
Copy the bundle to the server and install it as the service account's file:
sudo install -o privacyfence-org -g privacyfence-org -m 600 org_config.json \
/var/lib/privacyfence-org/.privacyfence/org/org_config.json
Copying the file is the only way to install a bundle on an organization server. The daemon reads
it once at startup, so restart the service after replacing it (sudo systemctl restart
privacyfence-org). It refuses to start, and says why, if the bundle is malformed, is missing its
server or idp section, is unsigned, or does not verify against the pinned key.
7. Reverse proxy and TLS¶
The daemon listens on 127.0.0.1:8765 and never needs to be reachable directly. Put a reverse
proxy in front of it that:
- terminates HTTPS for your public hostname;
- passes the original
Hostheader through unchanged. The daemon answers only requests addressed to the hostname of--server-issuer-url,127.0.0.1or::1; any otherHostgets400 Invalid Host header; - sets
X-Forwarded-ForandX-Forwarded-Proto. The daemon compares a browser'sOriginheader with the scheme it believes the request used, so withoutX-Forwarded-Proto: httpsevery approval, sign-out and settings change is refused as cross-origin; - does not buffer responses: the approvals page and
/mcpstream events; - allows request bodies of at least 50 MB, the size of an upload slot (section 13).
The daemon trusts forwarded headers only from the addresses you list with
--server-trusted-proxy, and from nowhere by default. A proxy on the same host must be listed as
127.0.0.1 (or ::1) like any other.
Rate-limit /register, /authorize and /token at the proxy. /register accepts anonymous
requests by design (that is dynamic client registration). The daemon caps registered clients at
2,000 and pending sign-ins at 1,000, but the proxy is the first line of defence against a request
loop.
Caddy¶
Caddy obtains and renews the certificate, keeps the Host header, sets both forwarded headers and
streams responses without further configuration:
pf.acme.example.com {
reverse_proxy 127.0.0.1:8765
}
nginx¶
With a certificate from your own CA or from certbot:
limit_req_zone $binary_remote_addr zone=pf_oauth:10m rate=30r/m;
server {
listen 80;
server_name pf.acme.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name pf.acme.example.com;
ssl_certificate /etc/letsencrypt/live/pf.acme.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pf.acme.example.com/privkey.pem;
client_max_body_size 60m;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 3600s;
location ~ ^/(register|authorize|token)$ {
limit_req zone=pf_oauth burst=20 nodelay;
proxy_pass http://127.0.0.1:8765;
}
location / {
proxy_pass http://127.0.0.1:8765;
}
}
proxy_read_timeout is long because a tool call can wait for a person to approve it.
TLS in the daemon instead¶
To have the daemon terminate TLS itself, pass --server-tls-cert and --server-tls-key and set
--server-bind-host to the address it should listen on. The certificate and key must be readable
by the service account. A proxy is still the better choice: it handles certificate renewal and rate
limiting.
8. Hardened systemd unit¶
Save this as /etc/systemd/system/privacyfence-org.service:
[Unit]
Description=PrivacyFence (organization deployment)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=privacyfence-org
Group=privacyfence-org
ExecStart=/opt/privacyfence/venv/bin/privacyfence-app
Restart=on-failure
RestartSec=5
UMask=0077
# Only for --audit-forwarding-http-bearer-token-env; the file holds NAME=token, mode 0600, owned by root.
# EnvironmentFile=/etc/privacyfence-org.env
# Filesystem: read-only everywhere except the data directory.
ProtectSystem=strict
ReadWritePaths=/var/lib/privacyfence-org
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
# Privileges and kernel surface.
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
RestrictSUIDSGID=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
Then start it and follow the log:
sudo systemctl daemon-reload
sudo systemctl enable --now privacyfence-org
journalctl -u privacyfence-org -f
On a good start the log contains a line beginning Org mode active -- MCP-over-HTTP at
https://pf.acme.example.com/mcp, which also names the identity provider, whether step-up is on and
which Host names are accepted.
Notes:
ReadWritePathsmust be the service account's home directory, because the data directory is~/.privacyfence/inside it.- If the daemon terminates TLS itself, keep the certificate and key outside
/home(hidden byProtectHome) or add them withBindReadOnlyPaths=. - On first start the daemon creates the install-wide
settings.yamlfrom its packaged example. That file must keepweb.mcp.enabled: true(the packaged value): with it off, the daemon starts but serves nothing in organization mode. - Only one daemon runs per data directory. A second start finds the lock, prints
PrivacyFence daemon is already running.and exits 0. PrivacyFence has no clustering or shared-state replication; for higher availability, fail over between hosts with the data directory on the standby only while the primary is stopped. - The daemon's own log is also written to
logs/privacyfence.login the data directory.
9. First sign-in and validation¶
Sign in and connect accounts¶
- Open
https://pf.acme.example.com/loginand sign in through your identity provider. You land on/connect. - Click Connect next to each service. Each one sends you to that service's consent screen and
back to
/connect, which then shows it as connected. Nothing needs restarting. - Open
/securityand enroll a passkey if step-up is on (section 12).
People reconnect or check a connection the same way; see connecting a service.
Add PrivacyFence to an AI client¶
Every client connects to https://pf.acme.example.com/mcp. The client discovers the authorization
server from /.well-known/oauth-protected-resource/mcp, registers itself at /register, and opens
the organization's sign-in page. After sign-in it holds its own tokens; there is nothing to copy or
paste. Access tokens last one hour and are refreshed silently; a refresh chain lasts 30 days, after
which the person signs in again.
- claude.ai: add a custom connector (in claude.ai's connector settings) with the URL
https://pf.acme.example.com/mcp, then click Connect on it and sign in. Leave the optional OAuth client ID and secret empty; claude.ai registers itself. On Team and Enterprise plans an owner adds the connector for the organization and each person connects it with their own sign-in. - Claude Desktop: add the same URL as a custom connector.
- Claude Code: run
claude mcp add --transport http privacyfence https://pf.acme.example.com/mcp, then/mcpinside Claude Code to sign in. - Other MCP-compatible clients: any client that supports the Streamable HTTP transport with OAuth 2.1 and dynamic client registration.
Each client registration shows up on the admin's AI systems page (section 11).
Validation checklist¶
Run these through the public hostname, not through 127.0.0.1:
- [ ]
curl -fsS https://pf.acme.example.com/.well-known/oauth-authorization-serverreturns JSON whoseissueris your public URL. - [ ] Opening
/approvalsin a private window redirects to/login. - [ ] Signing in shows your email address at the top right of the page.
- [ ] Someone outside
--authz-*(if set) is refused at sign-in. - [ ] An AI client connects and lists PrivacyFence's tools.
- [ ] A write request from the client appears in your
/approvals, and not in a second test user's. - [ ] Approving it (with step-up, if on) completes the request, and it appears under Settings → Audit Log.
- [ ] After
sudo systemctl restart privacyfence-org, the AI client keeps working without a new sign-in, and the browser asks you to sign in again.
10. Install-wide and per-user policy¶
Policy is split in two:
| What | Scope | Where | Who changes it |
|---|---|---|---|
| Privacy filter (which content categories are shown, redacted or blocked) and PII detection | Install-wide: applies to everyone | authority/config/settings.yaml in the data directory |
Admins, on Settings → Privacy Filter and Settings → General |
| Always-allow rules | Per person | users/<principal>/authority/config/settings.yaml |
Each person, on Settings → Auto-accept or with Always allow on an approval card |
Install-wide policy. A change made in Settings is written to the file and applies to everyone's
next request, without a restart. You can also edit the file by hand and restart the daemon, but do
not mix the two: saving from Settings rewrites the whole file from the copy the daemon loaded at
startup, which drops any hand edit made since, comments and formatting included. In organization
mode a privacy group that is missing from the file is treated as block (a desktop install uses
allow), and Privacy Filter marks each group that is relying on that default. State every group
explicitly so the file documents the intended policy. The categories and their meaning are in
approvals and policy.
Per-person rules. Nobody, admins included, can see or change another person's always-allow rules from the browser. An administrator who must review or remove someone else's rules edits that person's file on the server.
Settings pages each person sees:
| Page | Everyone | Admins only |
|---|---|---|
| Auto-accept | yes | |
| Audit Log (your own 20 most recent entries) | yes | |
| About | yes | |
| General (PII detection) | yes | |
| Privacy Filter | yes | |
| AI systems | yes |
Connector management, update checks, the log level, the Calendar free/busy and Gmail signature switches, and notification preferences are desktop-only settings and do not appear here.
11. AI systems¶
Every approval card and audit entry names the AI system that made the request. How much that name can be trusted depends on where it came from:
| Source | Recorded as agent_source |
Shown as |
|---|---|---|
| An admin pinned the client's registration to an AI system | oauth_client |
Verified, with the AI system's icon |
| The name the client gave when it registered, or in its MCP handshake | client_info |
Not verified, with the claimed name |
| Nothing | empty | Unrecognised AI system |
Any program that can reach /register can call itself anything, so only a pin makes a name
verified. To pin, an admin opens Settings → AI systems, which lists every registered client
with its registered name, client ID and when it was last used, checks that a registration really is
what it claims (for example by matching the last-used time to their own sign-in from claude.ai),
and clicks the AI system to pin it to. Unpin removes it.
- A pin names one registration, never a name. A client that registers again gets a new client ID and starts unverified.
- A registration unused for 180 days is removed the next time any client registers. Its pin stays listed under Stale pins, applies to nothing, and can be removed there.
- Pinning and unpinning are sensitive settings writes (section 12) and are recorded in the audit log with the admin's identity and the registration's claimed name.
- Pins are stored in
org/agent_pins.jsonin the data directory, not in the bundle, because client IDs are issued at runtime.
The reasoning is in ADR 0035, ADR 0036 and ADR 0037.
12. Approvals and step-up¶
Each person sees and decides only their own approvals, on /approvals. What gets gated, and how
cards and always-allow rules work, is the same as on a desktop install; see
approvals and policy.
Step-up asks for proof that a person, not a stolen session, is approving. In organization mode it is off until the bundle turns it on:
| Setting | Organization default | Flag |
|---|---|---|
| Step-up | off | --step-up-enabled |
| Scope | writes_and_pii_reads: every write, and every read in which PII was detected |
--step-up-scope |
| Accepted proof | a passkey, or signing in again at the identity provider | --step-up-require-passkey accepts a passkey only |
| Relying-party ID | the hostname of --server-issuer-url |
--step-up-rp-id |
With step-up on:
- A person with an enrolled passkey confirms with it. Passkeys are enrolled on
/security. - Without
--step-up-require-passkey, a person may instead sign in again at the identity provider (prompt=login,max_age=0, plus any--idp-step-up-acr-value). The sign-in must be the same person the approval belongs to. - With
--step-up-require-passkey, only a passkey is accepted. Someone with no passkey is sent to/securityto enroll one. - Approving several cards at once takes a single passkey confirmation that covers the selected set. Denying never needs step-up.
Sensitive settings writes. Adding or removing an always-allow rule, changing the privacy policy
or PII detection, and pinning or unpinning an AI system are sensitive: they change what gets gated
in future. When the bundle sets both --step-up-enabled and --step-up-require-passkey, each of
these needs a fresh passkey confirmation, exactly as on a desktop install. With either flag off,
they need only a signed-in session. A fresh identity-provider sign-in is never accepted for them. See
ADR 0034.
The full comparison of step-up on desktop and organization installs is in security and compliance.
13. File delivery¶
The server cannot write into a person's own folders, so download tools (drive_download_file,
gmail_download_attachment, confluence_download_attachment) hand the file back in one of two
ways, after the approval:
| File size | Delivery |
|---|---|
Up to --downloads-inline-max-bytes (8,000,000 bytes by default) |
Inline: the file's bytes are in the tool result. |
| Larger | Staged: the file is encrypted on disk and the tool result carries a single-use link that expires after --downloads-link-ttl-seconds (300 seconds by default). |
Which link a staged file gets:
| Setting | Link | Who can fetch it |
|---|---|---|
| Agent links on (default) | https://<host>/mcp-files/fetch/<token> |
Whoever holds the link, once, before it expires. The token is the credential, so the AI client can fetch the file itself. |
--no-agent-links |
https://<host>/downloads/<token> |
Only the person the file belongs to, signed in, in a browser. |
A missing, expired, already-used or other person's token all get the same 404. With
--downloads-disable-staging, a file too large to return inline is refused instead of staged, and
nothing is written to disk. With --downloads-inline-max-bytes 0, every file goes through a link.
Staged files live under users/<principal>/downloads/ in the data directory. Expired files are
removed the next time that person stages or claims a download, and every staged file is removed
when the daemon starts. The approval gate, not the delivery channel, is where the privacy decision
is made; see ADR 0017
and ADR 0028.
Uploads. A local file path means nothing to the server. An AI client first calls
privacyfence_create_upload_slot with a file name, gets back an upload_url, sends the file there
with an HTTP PUT, and passes the returned upload_id to the tool that needs the file (for
example drive_upload_file's upload_id, or upload:<upload_id> in a Gmail attachment list). A
slot takes up to 50,000,000 bytes, is valid for 10 minutes and can be used once, and only by the
person who created it. drive_upload_file also accepts the file inline as content_base64. Gmail
attachments are limited to 18,000,000 bytes in total per message.
14. Operations¶
Backup¶
The data directory is /var/lib/privacyfence-org/.privacyfence/. Back up all of it except the
paths marked "skip", with the daemon stopped (or from a filesystem snapshot):
| Path in the data directory | Contents |
|---|---|
org/org_config.json |
The installed bundle, including the identity provider and connector client secrets. |
org/org_config_signing_pubkey.txt |
The pinned signing key. |
org/oauth_clients.json |
Registered AI clients. Without it every client has to register again. |
org/oauth_refresh.json |
AI clients' refresh tokens, each sealed under its own token. |
org/agent_pins.json |
AI-system pins. |
authority/config/settings.yaml |
Install-wide policy. |
authority/logs/audit/ |
The install's own audit log and its chain key. |
deployment_id |
The install's ID, stamped on every audit entry. |
users/<principal>/credentials/ |
Each person's connector tokens. |
users/<principal>/authority/config/settings.yaml |
Each person's always-allow rules. |
users/<principal>/authority/webauthn_credentials.json, webauthn_recovery_code.json, step_up_state.json |
Each person's passkeys and recovery code. |
users/<principal>/logs/audit/ |
Each person's audit log and its chain key. |
users/<principal>/downloads/, users/<principal>/uploads/ |
Skip: short-lived encrypted staging. |
logs/privacyfence.log |
Optional: the runtime log. |
Also keep, outside the server: the bundle signing key, the systemd unit, the proxy configuration and any environment file.
A backup holds live connector tokens and client secrets. Encrypt it and restrict access as you
would the credentials themselves. To restore, stop the daemon, restore the directory with owner
privacyfence-org and modes unchanged (the daemon refuses to start if they are looser than
0700), start it, and run the validation checklist.
What a restart keeps¶
| State | After a restart |
|---|---|
| Registered AI clients and their refresh tokens | Kept. Clients reconnect silently. |
| Access tokens (one hour) | Lost. The client gets one 401 and refreshes. |
| Browser sessions (30 minutes idle, 24 hours at most) | Lost. People sign in again. |
| Pending approvals | Lost. The AI client has to make the request again. |
| Staged downloads and upload slots | Removed. |
| Policy, rules, passkeys, connector tokens, audit logs | Kept. |
Upgrade¶
Install the new version into the same virtual environment and restart. Your data is kept:
sudo /opt/privacyfence/venv/bin/pip install --upgrade privacyfence
sudo systemctl restart privacyfence-org
Read the release's entry in the changelog first, download the matching
build_org_bundle.py for future bundle changes, and back up before upgrading. To go back, install
the earlier version the same way (pip install privacyfence==X.Y.Z); that is safe only if the
earlier version reads the data as the newer one left it, so keep the pre-upgrade backup.
A settings.yaml that still has auto_accept_rules or auto_accept_grants sections is refused at
startup, unless it also carries the migrated_to_policy_v2 marker, in which case those sections
are removed and the file is rewritten.
Monitoring¶
- Probe:
GET https://pf.acme.example.com/.well-known/oauth-authorization-serverreturns200without authentication while the daemon and proxy are up. There is no separate health endpoint. - Service: alert on
systemctl is-failed privacyfence-organd on repeated restarts. - Log lines worth alerting on:
Refusing to start,Configuration error,Fatal error,Connector registry is at capacity,registered-client limit, and failures to reach the identity provider. - Disk: the data directory grows with audit logs and staged downloads.
- Audit forwarding: with
--enable-audit-forwarding, every audit entry is also sent to syslog or your HTTPS endpoint: the install's own log (authority/logs/audit/) and each person's (users/<principal>/logs/audit/), all carrying the install'sdeployment_id. The local logs stay the authoritative record, each with a hash chain over its entries.
Key and secret rotation¶
- Bundle signing key: generate a new key, build the bundle with it, stop the daemon, delete
org/org_config_signing_pubkey.txt, install the new bundle and start. The first start pins the new key and logs a warning saying so. - Identity provider client secret: rebuild with
--merge --mode organd every--server-*and--idp-*flag (section 6), install, restart. - Connector client secrets: rebuild with
--mergeand that connector's flags, install, restart. People's existing connector tokens keep working unless the provider revokes them.
Limits¶
| Limit | Value | Configurable |
|---|---|---|
| People with connectors loaded at once | 200; a person idle for 30 minutes is unloaded first. Beyond that, requests fail with Connector registry is at capacity. |
No |
| Registered AI clients | 2,000; registrations unused for 180 days are removed when a new client registers | No |
| Size of one client registration | 8 KiB | No |
| Sign-ins in progress | 1,000 at a time, each valid 5 minutes | No |
| Pending approvals | 50 across the install, 20 per person; a pending approval expires after 15 minutes | web.approvals in the install-wide settings.yaml (configuration reference) |
| Inline download | 8,000,000 bytes | --downloads-inline-max-bytes |
| Download link lifetime | 300 seconds | --downloads-link-ttl-seconds |
| Upload slot | 50,000,000 bytes, 10 minutes | No |
| Gmail attachments per message | 18,000,000 bytes in total | No |
| AI client access token | 1 hour | No |
| AI client refresh chain | 30 days | No |
| Browser session | 30 minutes idle, 24 hours at most | No |
15. Troubleshooting¶
The daemon will not start. Run journalctl -u privacyfence-org -n 50. The last lines name the
cause:
| Message contains | Fix |
|---|---|
has "mode": "org" but is not signed |
Rebuild the bundle with --sign-key. |
failed signing-key verification |
The bundle was signed with a different key than the pinned one. Sign it with the original key, or rotate the key (section 14). |
requires an "idp" section or requires org_config.json's "server"."issuer_url" |
Rebuild with --mode org and all --server-issuer-url and --idp-* flags. |
is not HTTPS -- org mode requires every IdP endpoint to be HTTPS, or a discovery error |
The identity provider's issuer or endpoints are not HTTPS, or the server cannot reach <issuer>/.well-known/openid-configuration. |
Refusing to start in organization mode: followed by a permissions finding |
A directory in the data directory grants group or other access. Set it back to 0700, owned by privacyfence-org. |
auto_accept_rules or auto_accept_grants |
See Upgrade. |
The service is running but nothing answers. Check that the install-wide settings.yaml has
web.mcp.enabled: true.
Every request returns 400 Invalid Host header. The proxy is not passing the public hostname
in Host, or --server-issuer-url names a different hostname. The startup log line lists the
accepted names.
Approving, signing out or saving settings fails with "cross-origin request rejected". The daemon
does not see the request as HTTPS. Make the proxy send X-Forwarded-Proto: https and list it with
--server-trusted-proxy.
A connector's consent screen shows redirect_uri_mismatch or a similar error. The connector app
does not list that service's exact redirect URI. Each Google service has its own
(/oauth/callback/gmail, /oauth/callback/apps_script and so on); see
section 5.
A service is missing from /connect. Its section is not in the bundle. Rebuild with its flags
and --merge, install and restart.
Admins do not see Privacy Filter or AI systems. The admin claim does not match. With Google as
the identity provider, use email (section 4). After changing it, restart and
sign out and back in.
Sign-in fails with an authorization error for some people. They are outside
--authz-allowed-domain or --authz-required-group.
Approving asks for a passkey that the person does not have. With --step-up-require-passkey,
they enroll one on /security first. Without it, they can choose to sign in again instead.
An AI client cannot register: "registered-client limit". 2,000 clients are registered and none
has been unused for 180 days. Remove org/oauth_clients.json entries you no longer need (with the
daemon stopped), or wait for the prune.
A download link returns 404. It has expired, was already used, or belongs to someone else. Ask the AI client to run the download again.
An upload through nginx fails with 413. Raise client_max_body_size above 50 MB
(section 7).