httpserver

package
v0.19.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 26, 2026 License: Apache-2.0 Imports: 98 Imported by: 0

README

HTCondor HTTP API Server

A RESTful HTTP API server for managing HTCondor jobs.

Features

  • Job Submission: Submit jobs via HTTP POST with HTCondor submit file
  • Job Queries: List and retrieve job details with ClassAd constraints and projections
  • File Transfer: Upload input files and download output files as tarballs
  • Authentication: Bearer token authentication forwarded to HTCondor schedd
  • Demo Mode: Built-in mini HTCondor setup for testing and development
  • OpenAPI: Full OpenAPI 3.0 specification for API documentation

Installation

cd cmd/htcondor-api
go build

Usage

Normal Mode (with existing HTCondor)
# Uses HTCondor configuration from environment
./htcondor-api

The server will:

  1. Read HTCondor configuration from standard locations
  2. Connect to the configured schedd
  3. Listen on port 8080 (default)
Demo Mode (standalone mini HTCondor)
# Starts mini HTCondor automatically
./htcondor-api --demo

Demo mode will:

  1. Create a temporary directory for mini HTCondor
  2. Write minimal HTCondor configuration
  3. Start condor_master as a subprocess
  4. Start the HTTP API server
  5. Clean up on Ctrl+C or SIGTERM
User Header Authentication (Demo Mode Only)

In demo mode, you can enable automatic token generation based on a custom HTTP header:

# Enable user header authentication
./htcondor-api --demo --user-header=X-Remote-User

With this option:

  • If the Authorization header is present, it's used as normal
  • If no Authorization header is present but X-Remote-User is set:
    • A signing key is automatically generated
    • A JWT token is created for the username in the header
    • This token is used to authenticate with HTCondor

This is useful for testing with reverse proxies that handle authentication and pass the username via header (e.g., Apache with mod_auth, nginx with auth_request).

Example:

# Submit a job using user header instead of bearer token
curl -X POST http://localhost:8080/api/v1/jobs \
  -H "X-Remote-User: alice" \
  -H "Content-Type: application/json" \
  -d '{"submit_file": "executable=/bin/echo\narguments=Hello\nqueue"}'

# List jobs
curl http://localhost:8080/api/v1/jobs \
  -H "X-Remote-User: alice"

Note: This feature is only available in demo mode and is intended for development/testing. In production, use proper HTCondor TOKEN authentication.

Custom Listen Address
./htcondor-api --listen :9000

API Endpoints

Job Management
Submit a Job
POST /api/v1/jobs
Authorization: Bearer <TOKEN>
Content-Type: application/json

{
  "submit_file": "executable = /bin/sleep\narguments = 60\nqueue"
}

Response:

{
  "cluster_id": 1,
  "job_ids": ["1.0"]
}
List Jobs
GET /api/v1/jobs?constraint=Owner=="user"&projection=ClusterId,ProcId,JobStatus
Authorization: Bearer <TOKEN>

Response:

{
  "jobs": [
    {
      "ClusterId": 1,
      "ProcId": 0,
      "JobStatus": 2,
      "Owner": "user"
    }
  ]
}
Get Job Details
GET /api/v1/jobs/1.0
Authorization: Bearer <TOKEN>

Response:

{
  "ClusterId": 1,
  "ProcId": 0,
  "JobStatus": 2,
  "Owner": "user",
  "Cmd": "/bin/sleep",
  ...
}
Remove Job (Not Yet Implemented)
DELETE /api/v1/jobs/1.0
Authorization: Bearer <TOKEN>
Edit Job (Not Yet Implemented)
PATCH /api/v1/jobs/1.0
Authorization: Bearer <TOKEN>
Content-Type: application/json

{
  "JobPrio": 10
}
File Transfer
Upload Job Input Files
PUT /api/v1/jobs/1.0/input
Authorization: Bearer <TOKEN>
Content-Type: application/x-tar

< input.tar

The tarball should contain the job's input files as specified in TransferInput.

Download Job Output Files
GET /api/v1/jobs/1.0/output
Authorization: Bearer <TOKEN>

> output.tar

Returns a tarball containing the job's output files.

Site submit-file policy

Some access points impose submit requirements a user cannot reasonably know. One deployment's schedd, for instance, refuses any job whose log = does not resolve inside the submitter's home directory — and the refusal arrives only at commit time, as an opaque transaction failure:

500: Job submission failed: failed to commit transaction: CommitTransaction
failed: Job event logs produced by 'log =' in the submit file ... must be
written inside your home directory ... (error code 22)

Two knobs let an operator satisfy that centrally instead of asking every user, and every template, to get it right:

knob applies
HTTP_API_SUBMIT_FILE_DEFAULTS only where the submit file is silent
HTTP_API_SUBMIT_FILE_OVERRIDES regardless of what the submit file says

Both apply to every submission — POST /api/v1/jobs, the templates and submit pages, interactive terminals, Jupyter, and MCP submit_job.

HTTP_API_SUBMIT_FILE_OVERRIDES = @=end
  log = /home/$ENV(USER)/htcondor-jobs.log
@end

Neither knob parses submit-file syntax. Submit commands are macro assignments evaluated when queue is reached, so the last assignment before queue is the effective one: defaults are prepended (anything the user writes later beats them) and overrides are spliced in just before queue (they beat everything above). Both blocks are wrapped in marker comments naming the knob they came from, so a generated submit file says where its extra lines originated.

Trust model: the same as HTTP_API_INTERACTIVE_EXTRA_SUBMIT — operator-only configuration, spliced in verbatim, no whitelist and no quoting. This is the operator's hook into job admission policy, equivalent in privilege to writing the schedd's site_local config. Setting neither knob leaves submit files byte-for-byte unchanged.

Placement (condor_placementd)

Issues and audits access-point credentials for identities that authenticate elsewhere. Every endpoint here requires membership in the web UI admin group (HTTP_API_WEBUI_ADMIN_GROUP): the placementd registers its commands at ADMINISTRATOR and this server talks to it as the access point's own identity, so a non-admin caller reaching these would be able to mint a bearer token for any identity in the daemon's map file.

The server discovers the daemon from PLACEMENTD_ADDRESS_FILE (or the default $(LOG)/.placementd_address), then from a PlacementD ad in the collector. A pool with no placementd is normal: discovery simply leaves this whole group disabled, and every endpoint but /status returns 503.

Feature probe
GET /api/v1/placement/status
{"available": true, "address": "<10.0.0.5:9618?...>"}

Answers even when no daemon was found (available: false plus a reason), so a UI can decide whether to offer the page at all.

List users
GET /api/v1/placement/users[?username=student1@example.edu]

Returns everyone in the map file, plus anyone still holding an unexpired token. The latter come back with authorized: false — their existing tokens keep working until they expire, but no new token can be issued.

List issued tokens
GET /api/v1/placement/tokens[?username=...][&token_id=...][&valid_only=true]

The daemon never deletes token rows, so an unfiltered query includes expired ones; pass valid_only=true for live tokens. The token string itself is not stored by the daemon and is never returned here — only its token_id (jti) and claims.

List authorizations
GET /api/v1/placement/authorizations[?username=...]

Returns each grantable authorization with the label, color, and description from the daemon's authorizations map file, so a UI renders the operator's own vocabulary. Passing a username narrows the list to what that user may request, and returns 403 if they are not mapped.

Mint a token
POST /api/v1/placement/login
Content-Type: application/json

{
  "username": "student1@example.edu",
  "authorizations": ["READ", "WRITE"],
  "project": "Chem101",
  "requester": "instructor@example.edu"
}
{"token": "eyJhbGciOi..."}
  • Omit authorizations for everything the user is entitled to. Naming any authorization they lack refuses the whole request rather than dropping the ones it cannot grant.
  • requester is for issuing on someone else's behalf; that identity must itself be mapped and hold the INSTRUCTOR authorization.
  • Beyond returning the token, a login creates the AP user record — and the project record, when a project is named — on the schedd if they do not exist. A login against a disabled user or project is refused rather than re-enabling it.
  • The response is the only time the token is retrievable. It is sent with Cache-Control: no-store.

Refusals from the daemon map to actionable statuses: 400 for a malformed request, 403 for a policy decision (unknown or expired user, an authorization or project they are not entitled to, a requester lacking INSTRUCTOR), 500 when the daemon could not record the token, and 502 when it could not be reached or its schedd leg failed.

Documentation
OpenAPI Schema
GET /openapi.json

Returns the full OpenAPI 3.0 specification.

Prometheus Metrics
GET /metrics

Returns Prometheus-formatted metrics about the HTCondor pool and the server process. Available when a collector is configured.

Example metrics:

  • htcondor_pool_machines_total - Total machines in the pool
  • htcondor_pool_cpus_total - Total CPU cores
  • htcondor_pool_cpus_used - Used CPU cores
  • htcondor_pool_memory_mb_total - Total memory in MB
  • htcondor_pool_jobs_total - Total jobs
  • process_resident_memory_bytes - Server memory usage
  • process_goroutines - Active goroutines

See ../metricsd/README.md for complete metrics documentation.

Offloading heavy reads to an htcondordb mirror

GET /api/v1/jobs and GET /api/v1/jobs/archive are the two heaviest things this API asks of a schedd: the archive read scans the on-disk history file and a broad job query walks the queue, both competing with scheduling and negotiation. When a synchronized htcondordb mirror is advertising to the collector and is current, those reads are served from it instead. The response says which backend answered:

{ "jobs": [ ... ], "total_returned": 12, "has_more": false, "source": "htcondordb",
  "source_note": "[source: htcondordb mirror \"db@ap40\"; synced 4s ago; served from the htcondordb mirror (job queue caught up)]" }

Routing needs no configuration beyond what the API already has (a collector to discover the mirror, and the HTCondor config to authenticate to it), and it is always best-effort: no mirror, a stale one, a durability gap, a dial or query error, or a result larger than the request's limit all fall back to the schedd with no visible difference other than "source": "schedd".

Freshness rules (shared with the MCP tools, in webapi/dbmirror):

  • Live jobs need a job queue the mirror reports caught up and synced within 60s — job state is latency-sensitive. A paginated request prefers the mirror, which resumes its own scan from the cursor the previous page returned rather than re-walking the queue; each backend issues a page token only it can read and declines the other's, so a walk stays where it started.
  • Archived jobs tolerate 300s of lag, because history is append-only. A request using schedd-specific scan semantics (since, scan_limit, or a forward scan) stays on the schedd, the only backend that reproduces them.
  • Only owner-scoped reads are routed. The mirror connection authenticates as this daemon rather than as the caller, so the schedd's per-caller ACL is not behind it; confining the query to the caller's own records is what keeps routing from widening access. /api/v1/jobs is owner-scoped by default; /api/v1/jobs/archive takes owned_by_me=true, and always applies it to a browser session that is not a Web UI admin.
Is it working?

Three answers, in increasing order of how much history they carry.

Per response. Every routed response carries "source" and "source_note", as above. Good for "did this query use the mirror?"

Right now. GET /readyz grows a dbmirror block whenever routing is configured. status is ok only when reads are actually routing — a mirror that is up but too far behind the tolerance is warning, because it is running without doing its job:

{ "status": "ok",
  "dbmirror": { "status": "ok", "required": false, "name": "db@ap40",
                "address": "<10.0.0.5:9619>", "job_queue_caught_up": true,
                "job_queue_staleness_seconds": 4, "history_staleness_seconds": 21,
                "history_gap": false, "jobs_tolerance_seconds": 60,
                "history_tolerance_seconds": 300 } }

Nothing discovered yet shows "status": "down" with last_error and the pinned_name / pinned_address that were configured — which is how a typo in either becomes visible next to the empty result it produced.

Over time. /metrics exports:

Metric Meaning
htcondor_api_dbmirror_decisions_total{table,decision,reason} Every routing decision. The served / declined ratio is how much load actually moved off the access point; reason says what to fix when it is not moving.
htcondor_api_dbmirror_up 1 when a mirror was discovered.
htcondor_api_dbmirror_job_queue_caught_up 1 when the mirror had drained job_queue.log at its last poll.
htcondor_api_dbmirror_job_queue_staleness_seconds Compare against jobs_tolerance_seconds (60): above it, live job reads silently go back to the schedd.
htcondor_api_dbmirror_history_staleness_seconds Same for history (tolerance 300).
htcondor_api_dbmirror_history_gap 1 when the mirror reported a durability gap, which stops all history routing.

The reason label is a closed set — stale, not_caught_up, no_mirror, history_gap, unsupported_query, dial_failed, and so on — so it is safe to alert on. stale and not_caught_up mean the syncer is behind; no_mirror means discovery is failing; unsupported_query means callers are asking for something the mirror structurally cannot serve, and no tuning changes that.

The staleness gauges are absent, not zero, when no mirror has been discovered: a zero would read as perfectly fresh.

Configuration

Routing needs no configuration in the common case, including when the API server and the database run on different hosts from the schedd — discovery goes through the pool's collector, which spans hosts, so the daemon finds the mirror wherever it runs. These knobs cover what the collector alone cannot decide:

Config Effect
HTTP_API_DBMIRROR_NAME Pin routing to the mirror advertising this Name. Set it when more than one htcondordb advertises to the pool: nothing in the ad says which schedd each one mirrors, so without a pin the freshest is chosen, which is a guess.
HTTP_API_DBMIRROR_ADDRESS Dial this sinful string instead of the advertised MyAddress, for a mirror reachable only over NAT or a tunnel. Freshness still comes from the collector ad — this changes where to connect, not whether the mirror is current enough to trust.
HTTP_API_DBMIRROR_REQUIRED Never fall back. A read the mirror cannot serve fails instead of becoming schedd load.

HTTP_API_DBMIRROR_REQUIRED inverts the default trade. Best-effort routing protects availability at the cost of the load guarantee: when the mirror lags, the queries quietly go back to the access point you were trying to protect. Required routing protects the load guarantee at the cost of availability — and makes the API's availability depend on the mirror's. A declined read becomes:

  • 503 when the mirror is absent, lagging, or unreachable. Worth retrying, and /readyz reports down so a load balancer sees it too.
  • 400 when the query itself cannot be served from the mirror (scan_limit, a since stop-scan, a forward scan, an unscoped query). Retrying changes nothing; the caller has to ask differently.

Both carry the reason in the error text. The setting applies to the MCP tools as well as these endpoints — one daemon, one policy.

Authentication

The server supports multiple authentication methods:

1. Bearer Token Authentication

The bearer token from the HTTP Authorization header is passed to the HTCondor schedd for authentication.

# Generate a token (requires HTCondor admin access)
condor_token_create -identity user@example.com > token.txt

# Use the token in API requests
curl -H "Authorization: Bearer $(cat token.txt)" \
  http://localhost:8080/api/v1/jobs

After successful OAuth2/SSO authentication, the server sets an HTTP session cookie that can be used for subsequent API requests. This enables browser-based web UIs to authenticate users via SSO and then interact with the REST API without managing bearer tokens.

Session Cookie Features:

  • Secure, HttpOnly cookies with SameSite protection
  • Configurable TTL (default: 24 hours)
  • Automatic cleanup of expired sessions
  • Username extracted from session for HTCondor operations

How it works:

  1. User authenticates via OAuth2/SSO flow (e.g., /mcp/oauth2/authorize)
  2. After successful authentication, server sets htcondor_session cookie
  3. Browser automatically includes cookie in subsequent API requests
  4. Server extracts username from cookie and generates HTCondor token

Example workflow:

# Step 1: User authenticates via browser (redirected to IDP)
# Browser visits: http://localhost:8080/mcp/oauth2/authorize?...

# Step 2: After successful auth, session cookie is set automatically

# Step 3: Browser can now make API requests without bearer token
# The session cookie is included automatically by the browser
curl -b cookies.txt http://localhost:8080/api/v1/jobs
3. User Header Authentication (Demo Mode)

In demo mode with --user-header flag, the server can extract username from a custom HTTP header and generate tokens. See demo mode section above.

Authentication Priority

The server checks for authentication in the following order:

  1. Bearer token in Authorization header (highest priority)
  2. Session cookie (for browser-based authentication)
  3. User header (if configured, for reverse proxy authentication)

Note: Token integration is partially implemented. The token is extracted but not yet fully integrated into the schedd authentication layer. See HTTP_API_TODO.md for details.

Configuration

HTCondor Configuration Parameters

The HTTP API server reads configuration from HTCondor's configuration system. Configuration can be placed in /etc/condor/config.d/ or any HTCondor configuration file.

HTTP Server Parameters
# Listen address (default: :8080)
HTTP_API_LISTEN_ADDR = :8443

# TLS/HTTPS configuration (optional - both required for TLS)
HTTP_API_TLS_CERT = /etc/condor/certs/server.crt
HTTP_API_TLS_KEY = /etc/condor/certs/server.key

# HTTP timeout configuration (optional, duration strings)
HTTP_API_READ_TIMEOUT = 30s      # Default: 30s
HTTP_API_WRITE_TIMEOUT = 30s     # Default: 30s
HTTP_API_IDLE_TIMEOUT = 2m       # Default: 120s

# Session configuration (optional)
HTTP_API_SESSION_TTL = 24h       # Default: 24h (session cookie lifetime)

# User header for authentication (optional)
HTTP_API_USER_HEADER = X-Forwarded-User

# JWT signing key path (optional, demo mode only)
HTTP_API_SIGNING_KEY = /etc/condor/keys/jwt_signing.key
MCP OAuth2 Configuration

Model Context Protocol (MCP) endpoints require OAuth2 authentication. Enable MCP support with:

# Enable MCP endpoints (default: false)
HTTP_API_ENABLE_MCP = true

# OAuth2 database path for storing clients and tokens
# Default: $(LOCAL_DIR)/oauth2.db or /var/lib/condor/oauth2.db
HTTP_API_OAUTH2_DB_PATH = /var/lib/condor/oauth2.db

# OAuth2 issuer URL (default: https://$(FULL_HOSTNAME) with non-standard port appended)
# If not explicitly set, the server will append the listen port if it's non-standard (not 443)
HTTP_API_OAUTH2_ISSUER = https://htcondor.example.com

# OIDC/SSO Configuration (optional, for external identity provider)

# Option 1: Use OIDC Discovery (recommended)
# The server will automatically discover auth and token endpoints from the provider
HTTP_API_OAUTH2_IDP = https://idp.example.com

# Option 2: Specify endpoints explicitly
# HTTP_API_OAUTH2_AUTH_URL = https://idp.example.com/auth/realms/master/protocol/openid-connect/auth
# HTTP_API_OAUTH2_TOKEN_URL = https://idp.example.com/auth/realms/master/protocol/openid-connect/token

# OAuth2 client credentials for SSO provider (e.g., Keycloak, Okta, Google)
HTTP_API_OAUTH2_CLIENT_ID = htcondor-api-client

# Client secret is read from a file (not directly in config for security)
# HTCondor configuration is considered public, so secrets must be in separate files
HTTP_API_OAUTH2_CLIENT_SECRET_FILE = /etc/condor/secrets/oauth2_client_secret

# Redirect URL for OAuth2 callback (default: derived from issuer + /oauth2/callback)
# Only set this if you need a different callback URL
# HTTP_API_OAUTH2_REDIRECT_URL = https://htcondor.example.com/oauth2/callback

# Username claim name (default: "sub")
# Specify which claim in the OAuth2 token contains the username
# Common alternatives: "preferred_username", "email", "username"
HTTP_API_OAUTH2_USERNAME_CLAIM = preferred_username

# User info endpoint URL (required for SSO integration with group-based access control)
# This endpoint should return user information including group membership
HTTP_API_OAUTH2_USERINFO_URL = https://idp.example.com/userinfo

# Claim name for groups in the userinfo response (default: "groups")
# The groups can be returned as either a JSON array or space-delimited string
HTTP_API_OAUTH2_GROUPS_CLAIM = groups

# Schedd selection (optional)
# SCHEDD_HOST names the schedd to talk to when neither -schedd nor SCHEDD_NAME
# is set: "hostname", "name@hostname", or either with a ":port". HTCondor leaves
# it undefined in most pools, so a value here is taken as deliberate and beats
# both the local schedd address file and picking one out of the collector.
SCHEDD_HOST = submit@ap1.example.edu

# Group-Based Access Control (optional)
# Control access to MCP endpoints and scope granting based on group membership

# Required group for any MCP access (if not set, all authenticated users can access)
# Users without this group will receive an "access_denied" error
HTTP_API_MCP_ACCESS_GROUP = mcp-users

# Required group for mcp:read scope (if not set, no users get read access by default)
# Users in this group will receive the mcp:read scope
HTTP_API_MCP_READ_GROUP = mcp-read

# Required group for mcp:write scope (if not set, no users get write access by default)
# Users in this group will receive both mcp:read and mcp:write scopes
HTTP_API_MCP_WRITE_GROUP = mcp-write

# Required group for the mcp:admin scope -- reading every user's jobs.
# This is how an OAuth caller becomes an admin. Entitlement comes from the
# group; the grant still requires the user to tick mcp:admin on the consent
# form, which is rendered UNCHECKED so that granting it is deliberate.
# Unset = nobody can be granted it (the opposite default from read/write,
# because "no admin group configured" has to mean nobody, not everybody).
HTTP_API_MCP_ADMIN_GROUP = mcp-admins

# Required group for the mcp:superuser scope -- CHANGING another user's jobs
# (remove, hold, release, edit). Deliberately not implied by mcp:admin.
HTTP_API_MCP_SUPERUSER_GROUP = mcp-superusers

# MCP admin users (optional, comma-separated) -- the stdio fallback ONLY.
#
# These subjects skip the owner-scope wrapper for READS, so they can query
# other users' jobs. They cannot act on them: that needs mcp:superuser.
#
# IMPORTANT: this list applies only to callers whose transport supplies no
# scopes -- the stdio server, where the process IS the user. It does NOT
# override a token. An OAuth caller who was not granted mcp:admin stays
# scoped to their own jobs even when listed here, because a token that
# withheld the scope is the ceiling and a subject list may not raise it.
# (It used to override, which silently defeated the unchecked consent box.)
#
# Matched exactly against the authenticated actor: the OAuth2 username claim
# for an OAuth2 caller, or the identity the schedd maps the caller to for a
# forwarded HTCondor IDTOKEN (typically user@uid.domain).
# Unset = every caller is scoped to their own jobs.
#
# `whoami` reports which of these applied, and why, for a given caller.
MCP_ADMIN_USERS = alice@example.edu, ops@example.edu

OIDC Discovery:

When HTTP_API_OAUTH2_IDP is set, the server will fetch the OIDC configuration from:

<IDP_URL>/.well-known/openid-configuration

This automatically discovers the authorization and token endpoints, making configuration easier and more maintainable. If discovery fails or you prefer explicit configuration, use HTTP_API_OAUTH2_AUTH_URL and HTTP_API_OAUTH2_TOKEN_URL instead.

Client Secret File:

Create a file with your OAuth2 client secret:

echo "your-secret-here" > /etc/condor/secrets/oauth2_client_secret
chmod 600 /etc/condor/secrets/oauth2_client_secret
chown condor:condor /etc/condor/secrets/oauth2_client_secret

User Header Authentication:

If HTTP_API_USER_HEADER is configured (e.g., from a reverse proxy), the user identity is taken from that header instead of the OAuth2 token subject. This allows integration with existing authentication systems:

# User identity from reverse proxy header
HTTP_API_USER_HEADER = X-Remote-User

# When both are configured:
# - User identity comes from the header (e.g., X-Remote-User: alice)
# - OAuth2 is still used for authorization and scoping
# - HTCondor tokens are generated for the header-provided username

This is useful when running behind Apache with authentication modules, nginx with auth_request, or similar setups where user authentication is handled upstream.

OIDC/SSO Integration:

When OIDC/SSO parameters are configured, the HTTP API server can:

  1. Redirect users to your identity provider for authentication
  2. Handle OAuth2 authorization code flow
  3. Exchange authorization codes for access tokens
  4. Generate HTCondor tokens based on authenticated identity (or user header if configured)
  5. Enable MCP clients to authenticate through your existing SSO infrastructure

Example OIDC Providers:

All major OIDC providers support automatic discovery. Set HTTP_API_OAUTH2_IDP to the issuer URL, and the server will automatically discover the endpoints:

  • Keycloak: Popular open-source identity and access management solution

    • IDP URL: https://keycloak.example.com/auth/realms/{realm}
    • Or explicit - Auth URL: https://keycloak.example.com/auth/realms/{realm}/protocol/openid-connect/auth
    • Token URL: https://keycloak.example.com/auth/realms/{realm}/protocol/openid-connect/token
  • Okta: Enterprise identity service

    • IDP URL: https://{domain}.okta.com/oauth2/default
    • Or explicit - Auth URL: https://{domain}.okta.com/oauth2/default/v1/authorize
    • Token URL: https://{domain}.okta.com/oauth2/default/v1/token
  • Google: Google OAuth2

    • IDP URL: https://accounts.google.com
    • Or explicit - Auth URL: https://accounts.google.com/o/oauth2/v2/auth
    • Token URL: https://oauth2.googleapis.com/token
  • GitHub: GitHub OAuth2 (does not support OIDC discovery, use explicit URLs)

    • Auth URL: https://github.com/login/oauth/authorize
    • Token URL: https://github.com/login/oauth/access_token
  • Azure AD: Microsoft Azure Active Directory

    • IDP URL: https://login.microsoftonline.com/{tenant}/v2.0
    • Or explicit - Auth URL: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize
    • Token URL: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token

Setting up OIDC Provider (Example with Keycloak):

  1. Create a new client in your OIDC provider with:

    • Client ID: htcondor-api-client
    • Client Protocol: openid-connect
    • Access Type: confidential
    • Valid Redirect URIs: https://htcondor.example.com/oauth2/callback
    • Web Origins: https://htcondor.example.com
  2. Configure scopes: openid, profile, email

  3. Note the client secret from the credentials tab

  4. Add the configuration to HTCondor config file

For complete MCP documentation including OAuth2 flows, see the MCP integration test in httpserver/mcp_integration_test.go.

Schedd Configuration
# Schedd configuration (required for normal mode)
SCHEDD_NAME = local
SCHEDD_HOST = 127.0.0.1
SCHEDD_PORT = 9618
Collector Configuration (Optional - for Metrics)
# Collector configuration (optional, enables /metrics endpoint)
COLLECTOR_HOST = 127.0.0.1
COLLECTOR_PORT = 9618

# Metrics cache TTL (optional, default: 10s)
METRICS_CACHE_TTL = 10s

When collector configuration is provided, the HTTP server automatically:

  1. Registers pool and process metrics collectors
  2. Exposes metrics at /metrics in Prometheus format
  3. Caches metrics according to configured TTL
Configuration Examples
Basic HTTP Server

Create /etc/condor/config.d/99-http-api.config:

HTTP_API_LISTEN_ADDR = :8080

Start the server:

./htcondor-api --mode=normal
HTTPS Server with TLS

Create /etc/condor/config.d/99-http-api.config:

HTTP_API_LISTEN_ADDR = :8443
HTTP_API_TLS_CERT = /etc/condor/certs/server.crt
HTTP_API_TLS_KEY = /etc/condor/certs/server.key
HTTP_API_READ_TIMEOUT = 45s
HTTP_API_WRITE_TIMEOUT = 45s
HTTP_API_IDLE_TIMEOUT = 5m

Generate self-signed certificates (for testing):

openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt \
  -days 365 -nodes -subj "/CN=localhost"

Start the server:

./htcondor-api --mode=normal

The server will listen on port 8443 with HTTPS.

MCP with OAuth2 and OIDC/SSO Integration

Create /etc/condor/config.d/99-http-api-mcp.config:

# Enable MCP endpoints
HTTP_API_ENABLE_MCP = true

# HTTPS configuration (required for production OAuth2)
HTTP_API_LISTEN_ADDR = :8443
HTTP_API_TLS_CERT = /etc/condor/certs/server.crt
HTTP_API_TLS_KEY = /etc/condor/certs/server.key

# OAuth2 provider configuration
HTTP_API_OAUTH2_DB_PATH = /var/lib/condor/oauth2.db
# Issuer is automatically constructed as https://$(FULL_HOSTNAME):8443
# since port 8443 is non-standard, or set explicitly:
# HTTP_API_OAUTH2_ISSUER = https://htcondor.example.com:8443

# OIDC/SSO provider integration using discovery (recommended)
HTTP_API_OAUTH2_IDP = https://keycloak.example.com/auth/realms/htcondor
HTTP_API_OAUTH2_CLIENT_ID = htcondor-mcp-client
HTTP_API_OAUTH2_CLIENT_SECRET_FILE = /etc/condor/secrets/oauth2_client_secret

# Redirect URL is automatically derived as $(HTTP_API_OAUTH2_ISSUER)/oauth2/callback
# Override only if needed:
# HTTP_API_OAUTH2_REDIRECT_URL = https://htcondor.example.com:8443/oauth2/callback

# HTCondor token generation for authenticated users
HTTP_API_SIGNING_KEY = /etc/condor/keys/POOL
TRUST_DOMAIN = htcondor.example.com
UID_DOMAIN = example.com

# Optional: Use user header from reverse proxy for identity
# HTTP_API_USER_HEADER = X-Remote-User

Create the OAuth2 client secret file:

mkdir -p /etc/condor/secrets
echo "your-keycloak-client-secret" > /etc/condor/secrets/oauth2_client_secret
chmod 600 /etc/condor/secrets/oauth2_client_secret
chown condor:condor /etc/condor/secrets/oauth2_client_secret

OAuth2 Endpoints Available:

  • GET /oauth2/authorize - OAuth2 authorization endpoint (redirects to OIDC provider)
  • POST /oauth2/token - OAuth2 token endpoint (exchanges auth code for access token)
  • POST /oauth2/introspect - OAuth2 token introspection endpoint
  • POST /mcp - MCP JSON-RPC endpoint (requires OAuth2 bearer token)

OAuth2 Flow for MCP Clients:

  1. Client initiates OAuth2 authorization code flow at /oauth2/authorize
  2. User is redirected to OIDC provider (e.g., Keycloak) for authentication
  3. After successful authentication, user is redirected back with authorization code
  4. Client exchanges authorization code for access token at /oauth2/token
  5. Client uses access token to make MCP requests to /mcp endpoint

Example MCP Request with OAuth2:

# Step 1: Get authorization code (typically done in browser)
# User visits: https://htcondor.example.com/oauth2/authorize?client_id=htcondor-mcp-client&response_type=code&redirect_uri=https://htcondor.example.com/oauth2/callback&scope=openid

# Step 2: Exchange code for token
curl -X POST https://htcondor.example.com/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code&code=AUTH_CODE&redirect_uri=https://htcondor.example.com/oauth2/callback&client_id=htcondor-mcp-client&client_secret=your-secret-here"

# Response:
# {
#   "access_token": "eyJhbGc...",
#   "token_type": "Bearer",
#   "expires_in": 3600
# }

# Step 3: Use access token for MCP requests
curl -X POST https://htcondor.example.com/mcp \
  -H "Authorization: Bearer eyJhbGc..." \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "method": "job.submit",
    "params": {
      "submit_file": "executable = /bin/echo\ntransfer_executable = False\narguments = Hello from MCP\nqueue"
    },
    "id": 1
  }'

For detailed MCP protocol documentation, see mcpserver/README.md.

Behind Reverse Proxy

If running behind a reverse proxy that sets authentication headers:

HTTP_API_LISTEN_ADDR = 127.0.0.1:8080
HTTP_API_USER_HEADER = X-Forwarded-User
HTTP_API_READ_TIMEOUT = 60s
HTTP_API_WRITE_TIMEOUT = 60s
Command Line Options

Command-line flags override HTCondor configuration:

# Override listen address
./htcondor-api --mode=normal --listen=:9090

# Override user header
./htcondor-api --mode=normal --user-header=X-Auth-User
Demo Mode

Demo mode uses a minimal configuration stored in a temporary directory. The configuration includes:

  • All daemons running locally (MASTER, COLLECTOR, NEGOTIATOR, SCHEDD, STARTD)
  • TOKEN authentication enabled
  • File transfer enabled
  • Jobs kept in queue for 24 hours after completion
# Start in demo mode
./htcondor-api --demo

# Demo mode with custom listen address
./htcondor-api --demo --listen=:9090

For complete configuration examples, see examples/http_api_config/.

Examples

See the examples/ directory in the repository for:

  • Python client examples
  • Shell script examples
  • Integration examples

Development

Project Structure
httpserver/
  ├── server.go       # HTTP server setup
  ├── routes.go       # Route configuration
  ├── handlers.go     # Request handlers
  ├── auth.go         # Authentication helpers
  └── openapi.go      # OpenAPI schema

cmd/htcondor-api/
  └── main.go         # Main binary with demo mode
Testing
# Run with demo mode for testing
go run cmd/htcondor-api/main.go --demo

# In another terminal, test the API
curl http://localhost:8080/openapi.json
Adding Features

See HTTP_API_TODO.md for a list of planned features and implementation notes.

Current Status

The HTTP API server implements the following features (see HTTP_API_TODO.md for complete details):

✅ Completed:

  • Bearer token authentication integrated with HTCondor schedd
  • Job submission via HTTP POST
  • Job queries with constraint and projection support
  • Individual job removal (DELETE /api/v1/jobs/{id})
  • Individual job editing (PATCH /api/v1/jobs/{id})
  • Bulk job operations (DELETE/PATCH /api/v1/jobs with constraints)
  • File transfer (upload input, download output)
  • Configuration via HTCondor config system
  • TLS/HTTPS support
  • Configurable HTTP timeouts

⏳ Pending:

  • Job history support (querying completed jobs)
  • Job status monitoring (SSE/WebSocket for real-time updates)
  • Enhanced demo mode (auto-generated tokens, test jobs)
  • Metrics and monitoring (Prometheus endpoint)

See HTTP_API_TODO.md for detailed implementation notes and examples.

License

See the repository LICENSE file for details.

Contributing

Contributions are welcome! Please:

  1. Check HTTP_API_TODO.md for planned features
  2. Follow existing code patterns
  3. Add tests for new functionality
  4. Update documentation

References

Documentation

Overview

Package httpserver provides HTTP API handlers for HTCondor operations.

Package httpserver provides HTTP API handlers for HTCondor operations.

Index

Constants

View Source
const (
	InteractiveWatchdogPollSec      = interactive.DefaultTerminalWatchdogPollSec
	InteractiveWatchdogFreshnessSec = interactive.DefaultTerminalWatchdogFreshnessSec
)

Watchdog timing for browser terminals, and how often the bridge heartbeats them.

The watchdog polls every InteractiveWatchdogPollSec; if .heartbeat is older than InteractiveWatchdogFreshnessSec it exits. The bridge sends a heartbeat every interactiveHeartbeatIntervalSec for as long as the WebSocket is open, and gives up after interactiveMaxIdleSec without a keystroke.

That idle bound used to be 60s, which is where a bug lived: a user who read output or thought for two minutes with the tab open stopped being heartbeated and lost their shell to the watchdog. The socket being open is the real "someone is there" signal -- a closed tab closes it -- so the keystroke clock now only exists to reclaim a tab left open and forgotten, and is set at the scale that behaviour actually happens on.

View Source
const (
	// DefaultMCPMaxRequestDuration is how long an MCP request may run while
	// it keeps making progress. Deliberately generous -- the point is to let
	// a watch wait for a job rather than for a timeout -- but finite, so a
	// stuck tool is bounded by something.
	DefaultMCPMaxRequestDuration = 15 * time.Minute

	// DefaultDeliverableWatchWait is how long a block can be expected to
	// SURVIVE, as against how long this server is willing to run it.
	//
	// The two are different numbers and only one of them is ours. The
	// deadline extension above settles what this daemon will hold a request
	// open for -- 14m30s once the reply margin is taken off the default hard
	// stop -- and says nothing about the MCP client at the other end, which
	// abandons a tool call on a timer of its own that this server cannot
	// see, cannot extend and is not told about. Measured against a live
	// deployment: a 45s and a 50s block both came back with their answer,
	// while 120s and 600s returned nothing at all -- not a timeout result, no
	// payload, just the client giving up. Reports of that timer put it near
	// 60s, and it varies by client: the CLI honours a per-server override,
	// the desktop app reportedly ignores it, and a progress notification
	// does not reset it.
	//
	// So a cap derived from our own deadline is a number no one can deliver,
	// and advertising it is worse than advertising a small one. The tool
	// description is what an agent plans against; told it may wait 14m30s it
	// asks for minutes, and every such call returns nothing -- which an agent
	// cannot tell apart from a lost answer, where a wait that runs out and
	// says "not yet" is unambiguous and costs one more turn. 45s is the
	// longest block observed to arrive, with the rest of the minute left for
	// the evaluation pass and the reply.
	//
	// This bounds the DEFAULT only. HTTP_API_MCP_WATCH_MAX_WAIT still wins
	// outright, in both directions: an operator who knows their clients are
	// configured for longer, or who has a gateway tighter than this, is the
	// only one who knows, and setting it is how they say so.
	DefaultDeliverableWatchWait = 45 * time.Second
)
View Source
const (
	// OracleScheddUserRec honors `condor_qusers -disable`, treating a user
	// record with no Enabled statement as no opinion.
	OracleScheddUserRec = "schedd-userrec"
	// OracleScheddUserRecStrict is OracleScheddUserRec plus "a user with no
	// record at all is revoked". Only correct in a pool that provisions a
	// record for every user; see UserRecordOracle.Strict.
	OracleScheddUserRecStrict = "schedd-userrec-strict"
	// OracleScheddACL probes the schedd's ACLs with DC_SEC_QUERY.
	OracleScheddACL = "schedd-acl"
	// OracleNone disables every oracle. It exists because a config file
	// cannot express Go's nil-versus-empty-slice distinction: an unset
	// HTTP_API_OAUTH2_REVOCATION_ORACLES means "use the defaults", so
	// there has to be a spelling for "I want none of them". Setting it
	// alongside other names is a configuration error.
	OracleNone = "none"
)

Recognized revocation oracle names. These are the values accepted by HandlerConfig.OAuth2RevocationOracles and by the HTTP_API_OAUTH2_REVOCATION_ORACLES config knob.

View Source
const DefaultMaxGrantLifetime = 30 * 24 * time.Hour

DefaultMaxGrantLifetime bounds how long a single consent can be stretched by refreshing. See Handler.oauth2MaxGrantLifetime.

View Source
const DefaultTokenRetention = 90 * 24 * time.Hour

DefaultTokenRetention is how long a dead token row is kept.

Ninety days is chosen to outlast the question an operator asks of this page ("what did that client do, and when did we cut it off?") without keeping rows nobody will ever read. Rows still in use are never touched whatever this says: the cutoff applies to tokens that have expired or been revoked.

Variables

View Source
var (
	ErrAuthorizationPending = &fosite.RFC6749Error{
		ErrorField:       "authorization_pending",
		DescriptionField: "The authorization request is still pending",
		CodeField:        http.StatusBadRequest,
	}
	ErrSlowDown = &fosite.RFC6749Error{
		ErrorField:       "slow_down",
		DescriptionField: "Client is polling too frequently",
		CodeField:        http.StatusBadRequest,
	}
	ErrExpiredToken = &fosite.RFC6749Error{
		ErrorField:       "expired_token",
		DescriptionField: "The device code has expired",
		CodeField:        http.StatusBadRequest,
	}
)

Device flow error codes (RFC 8628)

View Source
var ErrTokenAmbiguous = errors.New("more than one token matches that fingerprint")

ErrTokenAmbiguous reports that a fingerprint matches more than one token. Refusing is the point: the fingerprint is a truncated signature, so acting on "probably that one" would revoke somebody else's access.

View Source
var ErrTokenNotFound = errors.New("no token matches that fingerprint")

ErrTokenNotFound reports that no token matches a fingerprint.

Functions

func AuthenticatedViaAPIKey

func AuthenticatedViaAPIKey(ctx context.Context) bool

AuthenticatedViaAPIKey reports whether ctx was set up by the API-key auth path (rather than a JWT, OAuth2 token, or browser session). Some authorization decisions only make sense for API keys (e.g. "API keys must have an explicit scope; sessions don't").

func ConfigureSecurityForCollectorPing

func ConfigureSecurityForCollectorPing(token, serverName string) (*security.SecurityConfig, error)

ConfigureSecurityForCollectorPing builds a SecurityConfig used solely by the periodic collector ping. The collector ping is read-only — we just need *some* mutually agreeable handshake — so this offers both TOKEN and SSL. That's useful when the daemon's token does not match the collector's IssuerKeys (an issuer rotation, a misconfigured TrustDomain, etc.): SSL keeps /readyz green via a path that has nothing to do with JWT signing. The schedd path — which DOES need the token's identity for authz — keeps using TOKEN only.

SSL is always offered (even with no client cert/key on disk) because many collectors permit anonymous SSL: the client only verifies the server's cert and connects as ANONYMOUS@…, which is enough for a read-only ping. Cedar's SSL auth handles empty CertFile/KeyFile as "no client cert presented" and empty CAFile as "use the system trust store" — see cedar/security/ssl_auth.go and cmd/ssl-test/main.go.

serverName is used by cedar's SSL handshake for hostname/SAN verification. Without it, cedar falls back to the literal string "unknown" and verification fails ("certificate is valid for host.example.com, ..., not unknown"). Pass the bare hostname of the collector address — see hostFromCondorAddress in handler.go.

`token` may be empty; in that case only SSL is offered.

func ConfigureSecurityForToken

func ConfigureSecurityForToken(token string) (*security.SecurityConfig, error)

ConfigureSecurityForToken configures security settings to use the provided token This is a helper function to set up cedar's security configuration for TOKEN authentication

func ConfigureSecurityForTokenWithCache

func ConfigureSecurityForTokenWithCache(token string, sessionCache *security.SessionCache) (*security.SecurityConfig, error)

ConfigureSecurityForTokenWithCache configures security settings with an optional session cache If sessionCache is nil, the global cache will be used

func ConfigureSecurityForTokenWithCacheAndFallback

func ConfigureSecurityForTokenWithCacheAndFallback(token string, sessionCache *security.SessionCache, allowFSFallback bool) (*security.SecurityConfig, error)

ConfigureSecurityForTokenWithCacheAndFallback configures security settings with optional session cache and optional FS authentication fallback.

allowFSFallback semantics:

  • true: APPEND FS to the offered methods. No production call site passes this any more. It was used for user-header mode, on the premise that such a token was "generated locally per request and not signed with anything the schedd recognises" -- which was not true. extractOrGenerateToken signs the header-mode token with the same s.signingKeyPath and s.trustDomain as the session-mode one, so the schedd validates both identically, and appending FS only let FS win the negotiation and hide the caller's identity behind the daemon's OS user. Retained for the tests that pin the append/strip behaviour itself.

  • false (session/JWT mode): the token IS signed by us with the pool's signing key, the schedd validates it, and its `sub` claim is the user we want recorded as the job Owner. We therefore REMOVE FS from the offered methods, so the schedd can't pick it during negotiation. (Cedar's negotiation walks the server's preference order and selects the first method also offered by the client; HTCondor's default lists FS first, so leaving FS in the client's list lets FS win on a same-host schedd, and the schedd then records the OS user instead of the token's identity. We saw this in session_integration_test.go: jobs submitted via session cookie were owned by `vscode` — the test runner's UID — not by the JWT subject `testuser@trust.domain`.)

Authentication methods otherwise come from SEC_CLIENT_AUTHENTICATION_METHODS / SEC_DEFAULT_AUTHENTICATION_METHODS in the loaded HTCondor configuration. This was previously a hardcoded `[TOKEN]` list, which broke any pool that expects SSL alongside IDTOKENS.

Implementation: delegates to htcondor.NewClientSecurityConfig for the configured-methods-aware base, then applies the FS rule above. Other call sites (file_transfer, schedd_ssh, mcpserver) use NewClientSecurityConfig directly; the httpserver-only allowFSFallback knob lives here so we don't drag it into the root package's API.

func ContainsScope

func ContainsScope(ctx context.Context, scope string) bool

ContainsScope reports whether the request's API key was minted with the named scope. Returns false when the request was NOT authenticated via an API key (or was authenticated via one with different scopes). Use this in handlers that want to opt into API-key access.

func GenerateSigningKey

func GenerateSigningKey() ([]byte, error)

GenerateSigningKey generates a new signing key for token generation Returns the key content as bytes

func GetScheddWithToken

func GetScheddWithToken(ctx context.Context, schedd *htcondor.Schedd) (*htcondor.Schedd, error)

GetScheddWithToken creates a schedd connection configured with token authentication This wraps the schedd to use token authentication from context

func GetSecurityConfigFromToken

func GetSecurityConfigFromToken(ctx context.Context) (*security.SecurityConfig, error)

GetSecurityConfigFromToken retrieves the token from context and creates a SecurityConfig This is a convenience function for HTTP handlers to convert context token to SecurityConfig

func GetTokenFromContext

func GetTokenFromContext(ctx context.Context) (string, bool)

GetTokenFromContext retrieves the token from the context

func OAuth2CallbackPath added in v0.16.1

func OAuth2CallbackPath() string

OAuth2CallbackPath is the path this server advertises as its redirect URI: the plain one when the web UI is compiled in, the MCP-scoped one otherwise.

Both are always routed. A redirect URI is registered with the upstream identity provider, and an authorization already in flight across a restart or an upgrade comes back to whichever path it was started with; answering only the current one would strand it.

func ParseGroupSources added in v0.16.3

func ParseGroupSources(raw string) ([]string, error)

ParseGroupSources splits the comma-separated HTTP_API_GROUP_SOURCE.

Whitespace around each entry is trimmed, so "system, file:/etc/x" reads the way an administrator would write it.

Mixing "token" with a local source is refused rather than merged. The two are different trust bases -- one is what the identity provider asserted, the other is what this machine's account database says -- and unioning them would mean a provider could add a caller to any group the local policy checks. Choosing between them has to be deliberate.

func ParseTokenRetention added in v0.18.0

func ParseTokenRetention(raw string) (time.Duration, error)

ParseTokenRetention reads HTTP_API_TOKEN_RETENTION.

"0" or "off" keeps rows forever, which is a defensible choice for a deployment whose audit policy says so -- and an explicit one, rather than something reached by leaving a knob unset.

func WithRequestedRedirectURI added in v0.15.0

func WithRequestedRedirectURI(ctx context.Context, uri string) context.Context

WithRequestedRedirectURI notes the redirect_uri of the request being handled, for the loopback allowance described on withLoopbackRedirect.

func WithToken

func WithToken(ctx context.Context, token string) context.Context

WithToken creates a context that includes authentication token information This sets up the security configuration for cedar to use TOKEN authentication

Types

type AdminClient

type AdminClient struct {
	ID            string    `json:"id"`
	RedirectURIs  []string  `json:"redirect_uris,omitempty"`
	GrantTypes    []string  `json:"grant_types,omitempty"`
	ResponseTypes []string  `json:"response_types,omitempty"`
	Scopes        []string  `json:"scopes,omitempty"`
	Public        bool      `json:"public"`
	CreatedAt     time.Time `json:"created_at"`

	// ServiceSubject is the identity a client_credentials token from this
	// client asserts (the IDTOKEN subject the schedd authorizes). Empty unless
	// set by an admin; required before client_credentials will issue a token.
	ServiceSubject string `json:"service_subject,omitempty"`

	// Name is what the client called itself at registration (RFC 7591
	// client_name). Empty for seeded clients and for anything registered
	// before we started keeping it.
	Name string `json:"name,omitempty"`
	// Notes is the operator's own annotation, editable from the UI. It
	// is the only identifying field available for clients that predate
	// provenance tracking.
	Notes string `json:"notes,omitempty"`
	// Origin is "dynamic", "seeded", or empty for unknown. Empty is not
	// the same as "not dynamic" -- it means nobody recorded the answer.
	Origin string `json:"origin,omitempty"`
	// LastUsedAt is when this client last obtained a token. Absent means
	// never, which for a dynamically registered client usually means an
	// app registered once and never came back.
	//
	// Written on a debounced background flush, so it can lag real usage
	// by up to a flush interval. It is a "roughly when", not an audit
	// record; oauth2_access_tokens has the per-token history.
	LastUsedAt *time.Time `json:"last_used_at,omitempty"`
	// RecentUsers is a rolling sample of the last few distinct subjects
	// to obtain a token through this client, newest first.
	RecentUsers []AdminClientUse `json:"recent_users,omitempty"`
	// RefreshBlockedBy names what stops this client from ever receiving
	// a refresh token, so its users re-authorize on every access-token
	// expiry. Empty means nothing does -- or that the client has no
	// interactive flow and so has no user to inconvenience.
	RefreshBlockedBy []string `json:"refresh_blocked_by,omitempty"`
}

AdminClient is the SPA-facing shape for an OAuth2 client. We mirror only the fields useful for an "audit + cleanup" UI; secrets are never returned (they're hashed in storage anyway, but we still strip them out of the response shape on principle).

type AdminClientUse added in v0.13.0

type AdminClientUse struct {
	Subject string    `json:"subject"`
	At      time.Time `json:"at"`
}

AdminClientUse is one entry of a client's recent-users sample.

type AdminCondorConfigEntry

type AdminCondorConfigEntry struct {
	Key      string `json:"key"`
	Value    string `json:"value,omitempty"`
	Redacted bool   `json:"redacted,omitempty"`
	// IsDefault reports that this key still holds HTCondor's compiled-in
	// value — nothing in this deployment's config files or environment
	// touched it. Roughly a thousand of the ~1085 keys in a stock config
	// are in this state, which is what makes an unfiltered dump tedious
	// to read, so the SPA offers to hide them.
	IsDefault bool `json:"is_default,omitempty"`
}

AdminCondorConfigEntry is one (key, value) row from the HTCondor config that we surface to the admin info page. Sensitive values (matched by sensitiveCondorKeyPattern) come back with Redacted=true and an empty Value so the admin can SEE the key is set without the raw value rendering on screen — defense-in-depth even though HTCondor convention is that secrets are paths, not literals.

type AdminCondorConfigResponse

type AdminCondorConfigResponse struct {
	Configured bool                     `json:"configured"`
	Entries    []AdminCondorConfigEntry `json:"entries"`
	// ModifiedCount is how many entries this deployment actually set,
	// so the SPA can label its filter without counting client-side.
	ModifiedCount int `json:"modified_count"`
}

AdminCondorConfigResponse is the full readout. Configured=false means no HTCondor config object was wired into this server; treat as "feature unavailable on this deployment" client-side.

type AdminLogsResponse

type AdminLogsResponse struct {
	Enabled bool                  `json:"enabled"`
	Entries []logging.BufferEntry `json:"entries"`
}

AdminLogsResponse wraps the buffer entries with a hint when the buffer hasn't been initialized — the SPA shows a different empty state for "no logs yet" vs "feature not wired up".

type AdminRevokeRequest added in v0.13.0

type AdminRevokeRequest struct {
	// Subject is the user whose grants should be revoked. Matched exactly
	// against the subject recorded on each token row, which is the same
	// value the OAuth2 session carries (typically the username claim, not
	// the schedd's "user@domain" form).
	Subject string `json:"subject"`
}

AdminRevokeRequest is the body of a token revocation request.

type AdminRevokeResponse added in v0.13.0

type AdminRevokeResponse struct {
	Subject string `json:"subject"`
	// Revoked counts token rows deactivated across both issuers.
	Revoked int64 `json:"revoked"`
	// OAuth2 and IDP break that count down by issuer, so an operator can
	// tell whether the user held MCP grants, IDP grants, or both.
	OAuth2 int64 `json:"oauth2"`
	IDP    int64 `json:"idp"`
}

AdminRevokeResponse reports what was revoked.

type AdminRevokeTokenRequest added in v0.17.0

type AdminRevokeTokenRequest struct {
	// Kind is "access" or "refresh" -- which table the fingerprint is in.
	Kind string `json:"kind"`
	// Fingerprint is the redacted signature shown in the listing. The
	// trailing "..." may be included or not.
	Fingerprint string `json:"fingerprint"`
}

AdminRevokeTokenRequest names one token from the admin listing.

type AdminRevokeTokenResponse added in v0.17.0

type AdminRevokeTokenResponse struct {
	Revoked  int64  `json:"revoked"`
	ClientID string `json:"client_id"`
	Subject  string `json:"subject,omitempty"`
}

AdminRevokeTokenResponse reports what the revocation actually hit.

type AdminSetTokenScopesRequest added in v0.18.0

type AdminSetTokenScopesRequest struct {
	// Kind is "access" or "refresh" -- which table the fingerprint is in.
	Kind string `json:"kind"`
	// Fingerprint is the redacted signature shown in the listing.
	Fingerprint string `json:"fingerprint"`
	// Scopes is the set to keep. Anything currently granted and absent
	// here is removed; anything here that is not currently granted is an
	// error rather than an addition.
	Scopes []string `json:"scopes"`
}

AdminSetTokenScopesRequest narrows one grant from the admin listing.

type AdminSetTokenScopesResponse added in v0.18.0

type AdminSetTokenScopesResponse struct {
	ClientID string   `json:"client_id"`
	Subject  string   `json:"subject,omitempty"`
	Scopes   []string `json:"scopes"`
	Removed  []string `json:"removed,omitempty"`
	Added    []string `json:"added,omitempty"`
	Rows     int64    `json:"rows"`
}

AdminSetTokenScopesResponse reports what the narrowing actually did.

type AdminToken

type AdminToken struct {
	Kind            string `json:"kind"` // "access" or "refresh"
	SignaturePrefix string `json:"signature_prefix"`
	ClientID        string `json:"client_id"`
	// ClientName and Notes are the client's own label and the operator's
	// annotation, carried over from the clients page. A client id is a
	// generated string; "OpenClaw MCP" is what somebody reading this page
	// is actually looking for.
	ClientName string   `json:"client_name,omitempty"`
	Notes      string   `json:"notes,omitempty"`
	Subject    string   `json:"subject,omitempty"`
	Scopes     []string `json:"scopes,omitempty"`
	// AuthorizedScopes is everything this authorization ended with. Scopes
	// is the subset in force now, so the difference is what an operator
	// switched off and may switch back on.
	AuthorizedScopes []string  `json:"authorized_scopes,omitempty"`
	Active           bool      `json:"active"`
	RequestedAt      time.Time `json:"requested_at"`
	ExpiresAt        time.Time `json:"expires_at,omitempty"`
}

AdminToken is the SPA-facing shape for an OAuth2 access/refresh token row. We never expose the raw token signature — only its prefix as a fingerprint, so admins can correlate against logs without being able to use the token themselves.

type AdvertiseRequest

type AdvertiseRequest struct {
	Ad      *classad.ClassAd `json:"ad,omitempty"`       // Single ad (JSON body)
	Command string           `json:"command,omitempty"`  // Optional UPDATE command (e.g., "UPDATE_STARTD_AD")
	WithAck bool             `json:"with_ack,omitempty"` // Request acknowledgment
}

AdvertiseRequest represents a request to advertise to the collector

type AdvertiseResponse

type AdvertiseResponse struct {
	Success   bool     `json:"success"`
	Message   string   `json:"message,omitempty"`
	Succeeded int      `json:"succeeded"`        // Number of ads successfully advertised
	Failed    int      `json:"failed"`           // Number of ads that failed
	Errors    []string `json:"errors,omitempty"` // Error messages for failed ads
}

AdvertiseResponse represents the response from advertise

type AuthMeResponse

type AuthMeResponse struct {
	Authenticated bool     `json:"authenticated"`
	Username      string   `json:"username,omitempty"`
	Groups        []string `json:"groups,omitempty"`
	IsAdmin       bool     `json:"is_admin"`
	// SuperuserAllowed reports that this session MAY arm superuser mode --
	// the feature is configured and the session is in the superuser group.
	// It says nothing about whether the mode is currently on.
	SuperuserAllowed bool `json:"superuser_allowed"`
	// SuperuserActive reports that the mode is armed right now. The SPA
	// shows its warning banner on this, so every page can tell the user
	// that their next action may land on somebody else's job.
	SuperuserActive bool `json:"superuser_active"`
	// SuperuserExpiresAt is when the mode disarms itself. Surfaced so the
	// banner can say how long is left rather than have the mode silently
	// lapse mid-task.
	SuperuserExpiresAt *time.Time `json:"superuser_expires_at,omitempty"`
	// SuperuserIdentity is what actions will be attributed to on the schedd
	// while the mode is armed, and SuperuserNote explains it if that is not
	// the operator themselves.
	SuperuserIdentity string `json:"superuser_identity,omitempty"`
	SuperuserNote     string `json:"superuser_note,omitempty"`
}

AuthMeResponse describes the currently-authenticated browser session.

The Web UI uses this as its single source of truth for "is the user logged in", who they are, and whether to render admin pages. It is intentionally looser than /api/v1/whoami: it always returns 200 (with Authenticated=false when there is no session) so the SPA can render a landing page without going through error-handling.

type ClientOrigin added in v0.13.0

type ClientOrigin string

ClientOrigin says how a client came to exist. The empty value is meaningful and is not the same as "not dynamic": it marks a row that predates the column, where the answer was never recorded. Rendering that as "not dynamically registered" would assert something nobody checked.

const (
	// ClientOriginUnknown is a row that predates provenance tracking.
	ClientOriginUnknown ClientOrigin = ""
	// ClientOriginDynamic is a client that registered itself through
	// /mcp/oauth2/register (RFC 7591).
	ClientOriginDynamic ClientOrigin = "dynamic"
	// ClientOriginSeeded is a client this server created at startup.
	ClientOriginSeeded ClientOrigin = "seeded"
)

type CollectorAdsResponse

type CollectorAdsResponse struct {
	Ads []*classad.ClassAd `json:"ads"`
}

CollectorAdsResponse represents collector ads listing response

type Config

type Config struct {
	ListenAddr string // Address to listen on (e.g., ":8080")
	// MCPListenAddr, when set, serves MCP on its own listener instead of
	// alongside the web UI and the REST API. Empty is the default and
	// keeps everything on one port.
	//
	// A separate port is worth having only if the split is real, so the
	// protocol endpoint then answers there and not on the main listener.
	// The OAuth2 endpoints stay on both: a client that reaches either has
	// to be able to finish authenticating.
	MCPListenAddr string
	ScheddName    string // Schedd name
	ScheddAddr    string // Schedd address (e.g., "127.0.0.1:9618"). If empty, discovered from collector.

	// ScheddAddrDiscovered says ScheddAddr was resolved from the collector
	// rather than set by an operator.
	//
	// It matters because the two look identical here and behave
	// differently: a pinned address is honoured even when the collector
	// disagrees, while a discovered one must follow the collector. The
	// daemon resolves the address in main() and passes the result, so
	// without this every deployment that only sets SCHEDD_NAME looked
	// pinned -- and kept dialling the dead socket of a schedd that had
	// restarted, forever. See issue #308.
	ScheddAddrDiscovered bool
	UserHeader           string // HTTP header to extract username from (optional)
	// UserHeaderTrustedProxies is the CIDR list from which UserHeader
	// is honored. See HandlerConfig.UserHeaderTrustedProxies for
	// full docs and the security rationale. Configurable via
	// HTTP_API_USER_HEADER_TRUSTED_PROXIES.
	UserHeaderTrustedProxies []string
	// TrustedProxies lists CIDRs whose forwarded headers are honored for
	// access logging. Empty means none are. HTTP_API_TRUSTED_PROXIES.
	TrustedProxies []string
	// UserHeaderTrustAnyUnsafe disables the trusted-proxy gate and
	// honors UserHeader from any source. Demo / test only — see
	// HandlerConfig.UserHeaderTrustAnyUnsafe. Configurable via
	// HTTP_API_USER_HEADER_TRUST_ANY.
	UserHeaderTrustAnyUnsafe bool
	SigningKeyPath           string // Path to token signing key (optional, for token generation)
	TrustDomain              string // Trust domain for token issuer (optional; only used if UserHeader is set)
	UIDDomain                string // UID domain for generated token username (optional; only used if UserHeader is set)
	HTTPBaseURL              string // Base URL for HTTP API (e.g., "http://localhost:8080") for generating file download links in MCP responses

	// MCPBaseURL is the public base URL of the MCP listener, when MCP has
	// its own port and that port is published under a different origin
	// than the web UI. Empty means MCP is reached at the same base URL as
	// everything else, which is true whenever the ports are combined and
	// whenever a split is only local.
	//
	// It exists for one attribute: RFC 9728 requires the protected-resource
	// document to name the resource the client asked about, and a client
	// that reaches MCP on another origin asked about that origin. Naming
	// the web UI's instead makes the document one the client must reject.
	MCPBaseURL string
	// CCB decides how to reach a daemon behind a Condor Connection Broker:
	// on an inbound path of this server's own (see sharedportrouter), or by
	// having the broker relay. Set once by the operator and applied to every
	// surface that reaches into a running job -- a shell, a session, tailing
	// output -- so two of them cannot disagree. Nil keeps cedar's default,
	// which only works on a host the execute nodes can reach.
	CCB           *htcondor.CCBDialer
	TLSCertFile   string              // Path to TLS certificate file (optional, enables HTTPS)
	TLSKeyFile    string              // Path to TLS key file (optional, enables HTTPS)
	TLSCACertFile string              // Path to TLS CA certificate file (optional, for trusting self-signed certs)
	ReadTimeout   time.Duration       // HTTP read timeout (default: 30s)
	WriteTimeout  time.Duration       // HTTP write timeout (default: 30s)
	IdleTimeout   time.Duration       // HTTP idle timeout (default: 120s)
	Collector     *htcondor.Collector // Collector for metrics (optional)
	// JobQueueLogPath, if set, is the path to the schedd's job_queue.log; the
	// server mirrors it into a watch-enabled collection and serves
	// /api/v1/jobs/watch (SSE) from it. Empty disables the jobs watch endpoint.
	JobQueueLogPath string
	EnableMetrics   bool          // Enable /metrics endpoint (default: true if Collector is set)
	MetricsCacheTTL time.Duration // Metrics cache TTL (default: 10s)
	// MetricsPublic disables the API-key auth gate on /metrics.
	// Configurable via HTTP_API_METRICS_PUBLIC; see HandlerConfig.
	MetricsPublic bool

	// htcondordb mirror routing; see HandlerConfig for what each does.
	DBMirrorName     string          // HTTP_API_DBMIRROR_NAME
	DBMirrorAddress  string          // HTTP_API_DBMIRROR_ADDRESS
	DBMirrorRequired bool            // HTTP_API_DBMIRROR_REQUIRED
	Logger           *logging.Logger // Logger instance (optional, creates default if nil)
	JupyterWorkDir   string          // Per-instance scratch dir for JupyterLab submission artifacts; default <TempDir>/htcondor-api-jupyter
	// JupyterMaxLifetimeSec is the wall-clock ceiling on a JupyterLab
	// session, and JupyterKernelIdleSec the kernel-idle limit after
	// which JupyterLab culls and shuts down. Zero disables either.
	JupyterMaxLifetimeSec int
	JupyterKernelIdleSec  int

	// InteractiveExtraSubmit is an optional verbatim block of extra
	// HTCondor submit-file directives merged into every
	// interactive-terminal and Jupyter job. See
	// HandlerConfig.InteractiveExtraSubmit for the trust model and
	// full documentation. Configurable via
	// HTTP_API_INTERACTIVE_EXTRA_SUBMIT.
	InteractiveExtraSubmit string

	// DagmanPath is where condor_dagman lives on the access point, for
	// the submit_dag tool. See HandlerConfig.DagmanPath. Configurable
	// via HTTP_API_DAGMAN_PATH.
	DagmanPath string

	// DagmanEnvironment is extra environment for the DAGMan manager job.
	// See HandlerConfig.DagmanEnvironment. Configurable via
	// HTTP_API_DAGMAN_ENVIRONMENT.
	DagmanEnvironment map[string]string

	// InteractiveRequirements is an optional ClassAd expression ANDed into
	// the interactive terminal job's Requirements. See
	// HandlerConfig.InteractiveRequirements.
	InteractiveRequirements string

	// Build is the site's container-build configuration; see
	// mcpserver.BuildConfig and HandlerConfig.Build.
	Build mcpserver.BuildConfig
	// SubmitFileDefaults and SubmitFileOverrides are the site-wide
	// submit-file policy applied to EVERY submission -- REST, templates,
	// interactive, Jupyter and MCP alike. Defaults apply only where the
	// submit file is silent; overrides win over it. See
	// HandlerConfig for the trust model.
	// DBMirrorTokenSubject overrides the identity the htcondordb token
	// asserts. See HandlerConfig.
	DBMirrorTokenSubject string

	SubmitFileDefaults  string
	SubmitFileOverrides string

	// Batch-submission template paths.
	TemplateGlobalPath string // Optional YAML file with operator-curated templates
	// TemplateUserStoreDBPath is deprecated; the templates store
	// shares the unified DBPath. Kept so existing callers compile.
	TemplateUserStoreDBPath string //nolint:unused // back-compat; ignored.
	EnableMCP               bool   // Enable MCP endpoints with OAuth2 (default: false)
	// DBPath is the unified SQLite database file. See HandlerConfig.DBPath.
	DBPath string
	// KEKFilePath enables envelope encryption for long-lived secrets
	// in the DB. See HandlerConfig.KEKFilePath.
	KEKFilePath string
	// OAuth2DBPath is the legacy name for DBPath; kept for back-compat.
	OAuth2DBPath string
	OAuth2Issuer string // OAuth2 issuer URL (default: listen address)
	// MCPCIMDEnabled resolves an https:// MCP client_id as a Client ID Metadata
	// Document (public client); MCPCIMDAllowedHosts optionally restricts it.
	MCPCIMDEnabled      bool
	MCPCIMDAllowedHosts []string
	// MCPTokenExchangeIssuers: JSON list of trusted external issuers for token
	// exchange (HTTP_API_MCP_TOKEN_EXCHANGE_ISSUERS).
	MCPTokenExchangeIssuers string
	OAuth2ClientID          string   // OAuth2 client ID for SSO (optional)
	OAuth2ClientSecret      string   // OAuth2 client secret for SSO (optional)
	OAuth2AuthURL           string   // OAuth2 authorization URL for SSO (optional)
	OAuth2TokenURL          string   // OAuth2 token URL for SSO (optional)
	OAuth2RedirectURL       string   // OAuth2 redirect URL for SSO (optional)
	OAuth2UserInfoURL       string   // OAuth2 user info endpoint for SSO (optional)
	OAuth2Scopes            []string // OAuth2 scopes to request (default: ["openid", "profile", "email"])
	OAuth2UsernameClaim     string   // Claim name for username in token (default: "sub")
	OAuth2GroupsClaim       string   // Claim name for groups in user info (default: "groups")
	// OAuth2Requirements is a ClassAd expression evaluated against the
	// token's claims at login. Empty means no policy.
	OAuth2Requirements string

	// IdentityMapStrategies is the ordered list of ways to turn an OIDC
	// subject into a local account -- "gecos", "username", or both, as in
	// "gecos,username". Empty disables mapping entirely. When set, group
	// membership comes from the system rather than the token, and a
	// caller that maps to no single account is refused a session.
	IdentityMapStrategies []idmap.Strategy
	// IdentityGroupSources lists where group membership comes from, as
	// parsed specs: "system" and/or "file:<path>". Empty keeps the
	// token's groups claim, which is what a container wants because it
	// holds no account database to read. Independent of
	// IdentityMapStrategies: a deployment may want either, both, or
	// neither. Several sources are unioned, the way glibc merges NSS
	// services, so a site can carry directory groups and hand-maintained
	// ones at once.
	IdentityGroupSources []string
	// IdentityMapPasswdFile reads accounts from this file instead of
	// /etc/passwd. Empty means /etc/passwd, which is the only account
	// source that enumerates: the GECOS index cannot list accounts that
	// live only in a directory. Accounts it does map are still verified
	// against the live database, directory included.
	IdentityMapPasswdFile string
	// IdentityMapTTL is how long the GECOS index and the group lookups
	// are reused. Zero means five minutes.
	IdentityMapTTL time.Duration

	// IdentityMapStripDomain also tries the local part of a scoped
	// subject -- "bockelman@wisc.edu" as "bockelman". Only sound where
	// something else constrains which identity providers may log in,
	// since the local part is not unique across domains.
	IdentityMapStripDomain bool
	// OAuth2AccessTokenLifespan / OAuth2RefreshTokenLifespan control how long the
	// embedded MCP issuer's tokens are valid. Zero means "use the package default"
	// (1h access, 30d refresh). RefreshTokenLifespan must be >= AccessTokenLifespan.
	OAuth2AccessTokenLifespan  time.Duration
	OAuth2RefreshTokenLifespan time.Duration
	// OAuth2MaxGrantLifetime caps the total age of a grant measured from
	// the consent that created it, regardless of how often it is
	// refreshed. Defaults to DefaultMaxGrantLifetime (30 days) if zero.
	// See HandlerConfig.OAuth2MaxGrantLifetime.
	OAuth2MaxGrantLifetime time.Duration
	// OAuth2RevocationOracles selects the refresh-time revocation oracles.
	// Nil selects the default set; see HandlerConfig.OAuth2RevocationOracles
	// for the recognized names.
	OAuth2RevocationOracles []string
	MCPAccessGroup          string // Group required for any MCP access (empty = all authenticated)
	MCPReadGroup            string // Group required for read operations (empty = all have read)
	MCPWriteGroup           string // Group required for write operations (empty = all have write)
	// UpstreamRefresh is HTTP_API_UPSTREAM_REFRESH: auto, on or off.
	// Decides whether this server keeps the identity provider's refresh
	// token so it can ask about a user later. See upstream_refresh.go.
	UpstreamRefresh string
	// TokenRetention is HTTP_API_TOKEN_RETENTION: how long a dead token
	// row is kept before deletion. Empty means the default (90 days),
	// "off" keeps them forever. See oauth2_retention.go.
	TokenRetention string

	// MCPAdminGroup / MCPSuperuserGroup gate the two cross-user MCP
	// privileges. Empty means NOBODY, not everybody -- see
	// HandlerConfig for why these invert the default above.
	MCPAdminGroup     string
	MCPSuperuserGroup string
	ScheddHost        string // SCHEDD_HOST: the host (optionally name@host, optionally with a port) whose schedd to use
	MCPInstructions   string // Server-level instructions provided to all MCP agents (e.g., AP-specific guidance)
	// MCPDisabledTools (HTTP_API_MCP_DISABLED_TOOLS) names tools this site cannot
	// offer, as path.Match patterns separated by commas or whitespace.
	MCPDisabledTools string
	// MCPSkillsReloadInterval is how often that directory is re-read so a
	// checkout updated underneath this process is noticed without a
	// reconfigure. Zero disables the poll.
	MCPSkillsReloadInterval time.Duration
	// MCPSkillsDir publishes a directory of site-authored Markdown skills
	// to agents. Empty disables the feature.
	MCPSkillsDir    string
	MCPAdminUsers   []string // Authenticated subjects exempt from the MCP owner-scope wrapper
	WebUIAdminGroup string   // Group required for Web UI admin pages (empty disables admin UI). Configurable via HTTP_API_WEBUI_ADMIN_GROUP.
	// WebUIAccessGroup gates web interface login; empty falls back to
	// MCPAccessGroup. Comma-separated.
	WebUIAccessGroup string
	// SpoolBufferDir is where a cluster-wide upload buffers its tar
	// before fanning it out to each proc. Empty selects the system temp
	// directory.
	SpoolBufferDir string
	// SuperuserGroup gates superuser mode. Empty disables it. See
	// HandlerConfig.SuperuserGroup -- notably, it is NOT WebUIAdminGroup.
	SuperuserGroup string
	// SuperuserFallbackIdentity overrides the identity used when the
	// operator is not themselves a usable queue superuser. Empty selects
	// condor@$(UID_DOMAIN). See HandlerConfig.
	SuperuserFallbackIdentity string
	EnableIDP                 bool // Enable built-in IDP (always enabled in demo mode)
	// SeedDemoUser seeds a second, non-admin IDP account. Demo mode only;
	// see HandlerConfig.SeedDemoUser for why it is not keyed on EnableIDP.
	SeedDemoUser bool
	// IDPDBPath is deprecated; the IDP shares the unified DBPath.
	IDPDBPath string //nolint:unused // back-compat; ignored.
	IDPIssuer string // IDP issuer URL (default: listen address)
	// IDPAccessTokenLifespan / IDPRefreshTokenLifespan: see OAuth2*Lifespan above.
	IDPAccessTokenLifespan  time.Duration
	IDPRefreshTokenLifespan time.Duration
	SessionTTL              time.Duration  // HTTP session TTL (default: 24h)
	HTCondorConfig          *config.Config // HTCondor configuration (optional, used for LOCAL_DIR default)
	// PingInterval is the periodic collector/schedd ping cadence; zero
	// or negative disables it. See HandlerConfig.PingInterval.
	PingInterval time.Duration
	// MCPWatchMaxWait caps in-call MCP watch_jobs blocking; keep it
	// under the front gateway timeout. See HandlerConfig.MCPWatchMaxWait.
	MCPWatchMaxWait time.Duration
	// MCPMaxRequestDuration is the hard stop on an MCP request that keeps
	// making progress. See HandlerConfig.MCPMaxRequestDuration.
	MCPMaxRequestDuration time.Duration

	// MCPUseSDKTransport serves /mcp with the upstream MCP SDK's transport.
	// See HandlerConfig.MCPUseSDKTransport.
	MCPUseSDKTransport bool
	// RequiredCredentials names the OAuth service credentials that must
	// exist before a job may be submitted. See
	// HandlerConfig.RequiredCredentials.
	RequiredCredentials []string
	// CreddAddress pins the credd to talk to. See HandlerConfig.CreddAddress.
	CreddAddress       string
	StreamBufferSize   int                  // Buffer size for streaming queries (default: 100)
	StreamWriteTimeout time.Duration        // Write timeout for streaming queries (default: 5s)
	Token              string               // Token for daemon authentication (optional)
	Credd              htcondor.CreddClient // Optional credd client; defaults to in-memory implementation
	// Placementd is an optional condor_placementd client; nil means
	// "discover one". See HandlerConfig.
	Placementd htcondor.PlacementdClient

	// LLMAPIKeyFile is the path to a file holding the Anthropic API
	// key. Empty disables the chat endpoint. See HandlerConfig.
	LLMAPIKeyFile string
	// LLMAPIURL is an optional override for the upstream Anthropic
	// endpoint, useful when the operator runs an LLM gateway. Empty
	// = direct to api.anthropic.com.
	LLMAPIURL string
	// LLMModel overrides the default model id. Empty = package default.
	LLMModel string
	// LLMOperatorInstructionsFile is the path to a file with extra
	// system-prompt rules the operator wants the chat assistant to
	// follow on every turn. Empty disables. See HandlerConfig for
	// the file-mode requirement and rationale.
	LLMOperatorInstructionsFile string
}

Config holds server configuration

type DagGraphGroup added in v0.19.0

type DagGraphGroup struct {
	ID    string `json:"id"`
	Label string `json:"label"`
	// Description is what the group's nodes run, when it is known. It is
	// empty for a workflow read from a DOT file, which carries no submit
	// descriptions.
	Description string         `json:"description"`
	Count       int            `json:"count"`
	ParentIDs   []string       `json:"parent_ids"`
	Status      map[string]int `json:"status"`
}

DagGraphGroup is one collapsed group plus the state histogram of its members, which is what a drawing colours a shape by.

type DagGraphNode added in v0.19.0

type DagGraphNode struct {
	Name    string `json:"name"`
	GroupID string `json:"group_id"`
	State   string `json:"state"`
	// JobID, HoldReason and ExitCode are the "why" behind a state, and
	// they come from the queue or the archive even when the state itself
	// came from the status file -- a node the status file calls an error
	// is a number until something says which job failed and how.
	JobID      string `json:"job_id,omitempty"`
	HoldReason string `json:"hold_reason,omitempty"`
	ExitCode   *int   `json:"exit_code,omitempty"`
	// Detail is DAGMan's own note about the node, when the status file
	// carried one ("idle: 2 held", an error message, "Had an ancestor
	// node fail").
	Detail string `json:"detail,omitempty"`
	Source string `json:"source"`
}

DagGraphNode is one node's state.

type DagGraphResponse added in v0.19.0

type DagGraphResponse struct {
	Cluster int    `json:"cluster"`
	DagFile string `json:"dag_file"`
	// DotFile and StatusFile name the files this answer was read out of,
	// so a caller looking at the spool can find them.
	DotFile    string `json:"dot_file"`
	StatusFile string `json:"status_file,omitempty"`
	NodeCount  int    `json:"node_count"`
	EdgeCount  int    `json:"edge_count"`
	GroupCount int    `json:"group_count"`

	Groups []DagGraphGroup `json:"groups"`
	// Nodes is the per-node overlay, or null when the workflow is too
	// large to list node by node. NodesOmitted then says so and
	// NodesOmittedReason says why.
	Nodes              []DagGraphNode `json:"nodes"`
	NodesOmitted       bool           `json:"nodes_omitted,omitempty"`
	NodesOmittedReason string         `json:"nodes_omitted_reason,omitempty"`

	// ApproximateLayering is set when the grouping is not the exact
	// structural one -- a cycle, or a refinement that hit its bound -- so
	// the group layering is a best effort rather than a topology.
	ApproximateLayering bool `json:"approximate_layering,omitempty"`

	// StateSources names which of status-file/dot-file/queue/archive
	// actually contributed a node state.
	StateSources []string `json:"state_sources"`
	// StatusFileTime is the node status file's own timestamp, present
	// only when that file contributed. It matters: the queue half of this
	// response is live, while the status file is only as fresh as DAGMan's
	// last write plus the last whole-sandbox fetch.
	StatusFileTime int64 `json:"status_file_time,omitempty"`
	// Warnings carry what could not be consulted, so a missing archive
	// reads as "not available" rather than as "nothing ran".
	Warnings []string `json:"warnings,omitempty"`
	// FetchedAt is when the structure and the node states in this answer
	// were actually read out of the spool -- NOT when this response was
	// assembled. On a cached load those are minutes apart, and the panel
	// reports this as the age of what it is drawing.
	FetchedAt time.Time `json:"fetched_at"`
	// TookMS is how long this request spent producing the answer, which
	// is the number the panel shows and the one an operator compares a
	// cached load against a refresh with.
	TookMS int64 `json:"took_ms"`
}

DagGraphResponse is the body of GET /api/v1/jobs/{id}/dag.

type DashboardActivity added in v0.14.1

type DashboardActivity struct {
	HoldReasons []HoldReasonCount `json:"hold_reasons,omitempty"`

	RecentlySubmitted []RecentJob `json:"recently_submitted,omitempty"`
	RecentlyStarted   []RecentJob `json:"recently_started,omitempty"`
	RecentlyHeld      []RecentJob `json:"recently_held,omitempty"`
	RecentlyCompleted []RecentJob `json:"recently_completed,omitempty"`

	// CompletedAvailable is false when nothing could answer "what
	// finished recently". Reported rather than left as an empty list,
	// which would read as "nothing finished".
	CompletedAvailable bool `json:"completed_available"`
	// CompletedPartial says the list came from the queue alone -- the
	// handful still in JobStatus == 4 before the reaper destroys them.
	// That is minutes of history at best, and a viewer told otherwise
	// would read a short list as a quiet access point.
	CompletedPartial bool `json:"completed_partial"`

	// HoldWindowSeconds is the span the hold breakdown covers. Reported
	// rather than assumed: the rows answer "why did jobs BECOME held
	// recently", which is a different question from the HELD tile beside
	// them, and a reader who takes it for the latter will conclude the
	// backlog vanished.
	HoldWindowSeconds int64 `json:"hold_window_seconds,omitempty"`

	// Source is what answered, and ComputedAt when. A cached snapshot is
	// minutes old by design; saying so is the difference between a stale
	// number and a wrong one.
	Source     string `json:"source"`
	ComputedAt int64  `json:"computed_at"`
}

DashboardActivity is the "how is this access point doing" half of the dashboard: why jobs are held, and what has changed lately.

type DashboardActivityResponse added in v0.14.2

type DashboardActivityResponse struct {
	Activity DashboardActivity `json:"activity"`
	// Goodput is absent where nothing could answer it -- no history
	// archive means no rate, and an omitted field says that where a
	// zeroed one would read as "everything failed".
	Goodput *GoodputSummary `json:"goodput,omitempty"`
}

DashboardActivityResponse is the slow half of the dashboard.

type DashboardResponse

type DashboardResponse struct {
	Username     string         `json:"username"`
	JobsByStatus map[string]int `json:"jobs_by_status"`
	JobsTotal    int            `json:"jobs_total"`
	// Activity is the "how is this access point doing" half: why jobs
	// are held, and what changed recently. Computed from the same walk
	// as the counts.
	// Activity and Goodput are served by /api/v1/dashboard/activity and
	// omitted here. They stay on the type so a client that has not moved
	// yet gets a response missing two optional fields rather than one
	// that fails to decode.
	Activity DashboardActivity `json:"activity,omitzero"`
	// Goodput is absent where nothing could answer it -- no history
	// archive means no rate, and an omitted field says that where a
	// zeroed one would read as "everything failed".
	Goodput *GoodputSummary `json:"goodput,omitempty"`
}

DashboardResponse summarizes the user's queue at the AP. It is a minimal shape on purpose; we'll grow it (transfer history, recent completions, user-level quota) in PR (b)/(c) once the SPA has the basics.

type DeviceAuthorizationResponse

type DeviceAuthorizationResponse struct {
	DeviceCode              string `json:"device_code"`
	UserCode                string `json:"user_code"`
	VerificationURI         string `json:"verification_uri"`
	VerificationURIComplete string `json:"verification_uri_complete,omitempty"`
	ExpiresIn               int    `json:"expires_in"`
	Interval                int    `json:"interval,omitempty"`
}

DeviceAuthorizationResponse represents the response from device authorization endpoint

type DeviceCodeHandler

type DeviceCodeHandler struct {
	// contains filtered or unexported fields
}

DeviceCodeHandler implements the OAuth 2.0 Device Authorization Grant (RFC 8628)

func NewDeviceCodeHandler

func NewDeviceCodeHandler(storage *OAuth2Storage, config *fosite.Config) *DeviceCodeHandler

NewDeviceCodeHandler creates a new device code handler

func (*DeviceCodeHandler) HandleDeviceAccessRequest

func (h *DeviceCodeHandler) HandleDeviceAccessRequest(ctx context.Context, deviceCode string, session fosite.Session) (fosite.Requester, error)

HandleDeviceAccessRequest handles token requests with device_code grant type

func (*DeviceCodeHandler) HandleDeviceAuthorizationRequest

func (h *DeviceCodeHandler) HandleDeviceAuthorizationRequest(ctx context.Context, client fosite.Client, scopes []string) (*DeviceAuthorizationResponse, error)

HandleDeviceAuthorizationRequest handles the device authorization endpoint

type ErrorResponse

type ErrorResponse struct {
	Error   string `json:"error"`
	Message string `json:"message,omitempty"`
	Code    int    `json:"code"`
}

ErrorResponse represents an error response body

type ExitCodeCount added in v0.14.1

type ExitCodeCount struct {
	// Code is the exit status. Signal is set instead when the job was
	// killed, in which case Code has no meaning.
	Code   int64 `json:"code"`
	Signal bool  `json:"signal"`
	Count  int   `json:"count"`
	// Seconds is the wall clock spent on jobs that ended this way.
	Seconds int64 `json:"seconds"`
}

ExitCodeCount is one way jobs finished badly.

type GoodputSummary added in v0.14.1

type GoodputSummary struct {
	WindowHours int `json:"window_hours"`
	// Since is the exact lower bound the numbers were computed over, so
	// a drill-down can ask the archive the same question and get a list
	// whose length matches the count that was clicked. Deriving it in
	// the browser from WindowHours would drift by the age of the
	// response.
	Since int64 `json:"since"`

	// Succeeded is jobs that ran to completion and said so: exit 0, not
	// killed by a signal.
	Succeeded int `json:"succeeded"`
	// Failed is jobs that ran to completion and reported a problem --
	// a non-zero exit or a fatal signal.
	Failed int `json:"failed"`
	// Unfinished is jobs that left without an outcome, which is what a
	// removal looks like from here. Counted separately rather than
	// folded into failures: a job its owner cancelled is not the access
	// point going wrong.
	Unfinished int `json:"unfinished"`

	// GoodSeconds and BadSeconds are wall-clock time on the execute
	// node, split the same way. This is the half that makes the panel
	// worth having.
	GoodSeconds int64 `json:"good_seconds"`
	BadSeconds  int64 `json:"bad_seconds"`

	// TopFailures ranks the exit codes behind Failed, because "412 jobs
	// failed" and "412 jobs failed with exit 127" are different amounts
	// of information.
	TopFailures []ExitCodeCount `json:"top_failures,omitempty"`
}

GoodputSummary is the outcome of everything that finished in the window.

type GrantRef added in v0.17.0

type GrantRef struct {
	RequestID string
	ClientID  string
	Subject   string
	Signature string
}

GrantRef identifies one authorization grant: the pairing of an access token with the refresh token minted alongside it.

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler represents the HTTP API handler that can be embedded in any HTTP server

func NewHandler

func NewHandler(cfg HandlerConfig) (*Handler, error)

NewHandler creates a new HTTP API handler that can be embedded in any HTTP server

func (*Handler) AdvertiseAugment added in v0.14.0

func (h *Handler) AdvertiseAugment() func(*classad.ClassAd)

AdvertiseAugment returns the daemon.AdvertiseConfig.Augment callback that adds this API server's attributes to its collector ad. Exported so the daemon bootstrap in package main can wire it without reaching into unexported state.

func (*Handler) GetOAuth2Provider

func (h *Handler) GetOAuth2Provider() *OAuth2Provider

GetOAuth2Provider returns the OAuth2 provider (for testing)

func (*Handler) GetSchedd

func (h *Handler) GetSchedd() *htcondor.Schedd

GetSchedd returns the current schedd instance (thread-safe)

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler interface.

Every request flows through the metrics middleware first so the HTTP request counters / duration histogram / in-flight gauge cover every route uniformly — not just the ones we remembered to wrap in setupRoutes. The middleware short-circuits /metrics itself to avoid Prometheus scrapes self-instrumenting.

Security headers are emitted on every response (see applySecurityHeaders). Setting them at the top wrapper guarantees no route can opt out by accident — handlers further down the stack can still override (e.g. relaxing frame-ancestors for an embeddable widget) but the secure defaults are present until explicitly changed.

The response is also wrapped in a status-capturing writer so we can call tokenCache.MarkValidated when a request bearing a JWT returns 2xx. This is the "lazy validation" pattern: there is no local way to verify the JWT signature (the only authoritative validator is the schedd's CEDAR handshake), so we defer to "the handler completed successfully" as evidence that the schedd accepted the token — and only at that point trust the token's `sub` claim for identity decisions like ownedByMe filtering.

func (*Handler) SetMCPAccessGroups added in v0.15.0

func (h *Handler) SetMCPAccessGroups(raw string)

SetMCPAccessGroups installs the groups required for MCP access.

func (*Handler) SetMCPAdminGroups added in v0.17.0

func (h *Handler) SetMCPAdminGroups(raw string)

SetMCPAdminGroups installs the groups whose members may read every user's jobs through MCP. Emptying it revokes the privilege for everyone -- these two, unlike the read/write groups above, have no fallback to a broader group.

func (*Handler) SetMCPDisabledTools added in v0.18.0

func (h *Handler) SetMCPDisabledTools(spec string)

SetMCPDisabledTools installs the site's disabled-tool patterns.

No-op when MCP is disabled, since there is then no server to tell.

func (*Handler) SetMCPInstructions added in v0.14.4

func (h *Handler) SetMCPInstructions(instructions string)

SetMCPInstructions installs new deployment-specific MCP instructions. No-op when MCP is disabled, since there is then no server to tell.

Only sessions that initialize after this call see the new text; MCP hands an agent its instructions once, in the initialize response.

func (*Handler) SetMCPReadGroups added in v0.15.0

func (h *Handler) SetMCPReadGroups(raw string)

SetMCPReadGroups installs the groups required for MCP read access.

func (*Handler) SetMCPSkillsDir added in v0.15.0

func (h *Handler) SetMCPSkillsDir(dir string)

SetMCPSkillsDir reloads the site skill library.

Called on every reconfigure, not only when the configured path changes: the usual reason to reload is that the checkout the path points at has been updated, which a diff of the setting cannot see. Reading a few dozen Markdown files is cheap enough to do unconditionally.

No-op when MCP is disabled, since there is then no server to tell.

func (*Handler) SetMCPSuperuserGroups added in v0.17.0

func (h *Handler) SetMCPSuperuserGroups(raw string)

SetMCPSuperuserGroups installs the groups whose members may change another user's jobs through MCP.

func (*Handler) SetMCPWriteGroups added in v0.15.0

func (h *Handler) SetMCPWriteGroups(raw string)

SetMCPWriteGroups installs the groups required for MCP write access.

func (*Handler) SetSuperuserGroups added in v0.15.0

func (h *Handler) SetSuperuserGroups(raw string)

SetSuperuserGroups installs the groups permitted to use superuser mode.

Membership only. Superuser mode builds a policy object and a signing identity at startup, and only when a group was configured then, so this cannot switch the feature ON in a running daemon -- it says so rather than appearing to succeed. Emptying it DOES switch it off, since every check goes through the group list.

func (*Handler) SetWebUIAccessGroups added in v0.15.0

func (h *Handler) SetWebUIAccessGroups(raw string)

SetWebUIAccessGroups installs the groups required to log in to the web interface. Emptying it restores the fallback to the MCP access groups.

func (*Handler) SetWebUIAdminGroups added in v0.15.0

func (h *Handler) SetWebUIAdminGroups(raw string)

SetWebUIAdminGroups installs the groups required for the admin pages.

Emptying it disables the admin UI, which is what an empty value has always meant, so this can switch the admin surface off without a restart as well as change who reaches it.

func (*Handler) SetupRoutes

func (h *Handler) SetupRoutes(setupFunc func(*http.ServeMux))

SetupRoutes sets up the HTTP routes on the handler's multiplexer This should be called by Server.NewServer or by users who create a Handler directly

func (*Handler) Start

func (h *Handler) Start(ctx context.Context, ln net.Listener, protocol string) error

Start initializes the handler and starts background goroutines. The provided context controls the handler's lifetime - when the context is cancelled, the handler will gracefully shut down all background goroutines.

This method should be called by Server.Start() or Server.StartTLS() before serving requests.

func (*Handler) Stop

func (h *Handler) Stop(ctx context.Context) error

Stop gracefully stops all background goroutines and closes providers. This method is called when the handler's context is cancelled (via Server.Shutdown). The background goroutines are responsible for watching their context and exiting when done.

func (*Handler) UpdateOAuth2RedirectURL

func (h *Handler) UpdateOAuth2RedirectURL(redirectURL string)

UpdateOAuth2RedirectURL updates the OAuth2 redirect URL for SSO integration

func (*Handler) UpdateSchedd

func (h *Handler) UpdateSchedd(newAddress string)

UpdateSchedd updates the schedd instance with a new address (thread-safe). On change, logs both addresses, the age of the previous address, and — when both addresses are shared-port — the old and new sock= IDs so it's obvious whether a schedd restart drove the update (sock= changed) versus a network-level address shift (host:port changed but sock= stable).

Always sets scheddAddrLastConfirmedAt to now: the caller has just talked to the collector successfully, regardless of whether the address differs from the cached value.

type HandlerConfig

type HandlerConfig struct {
	ScheddName string // Schedd name
	ScheddAddr string // Schedd address (e.g., "127.0.0.1:9618"). If empty, discovered from collector.

	// ScheddAddrDiscovered says ScheddAddr was resolved from the collector
	// rather than set by an operator.
	//
	// It matters because the two look identical here and behave
	// differently: a pinned address is honoured even when the collector
	// disagrees, while a discovered one must follow the collector. The
	// daemon resolves the address in main() and passes the result, so
	// without this every deployment that only sets SCHEDD_NAME looked
	// pinned -- and kept dialling the dead socket of a schedd that had
	// restarted, forever. See issue #308.
	ScheddAddrDiscovered bool
	// ScheddHost is the SCHEDD_HOST setting: the host (optionally
	// "name@host", optionally with a port) whose schedd to talk to.
	// Consulted when neither ScheddAddr nor ScheddName is set; it picks
	// that host's schedd rather than whichever one the collector
	// happens to list first.
	ScheddHost string
	// InteractiveExtraSubmit holds extra HTCondor submit-file
	// directives merged into the submit file produced for each
	// interactive-terminal and JupyterLab job. The string value is
	// spliced in verbatim just before the `queue` directive, so it
	// can override or extend anything the builder emits (typical
	// use: pin an accounting group, add +ProjectName, set a
	// site-wide `requirements` fragment, force `concurrency_limits`,
	// etc.).
	//
	// Trust model: the value comes from operator-only configuration
	// (HTCondor config or env), so it's inserted into every submit
	// file verbatim — no whitelist, no quoting. Treat this as the
	// operator's hook into job admission policy, equivalent in
	// privilege to writing the schedd's site_local config.
	//
	// Multi-line content is supported via HTCondor config syntax (a
	// quoted multi-line value, or backslash continuations). Empty
	// disables the feature. Configurable via
	// HTTP_API_INTERACTIVE_EXTRA_SUBMIT.
	InteractiveExtraSubmit string

	// DagmanPath is where condor_dagman lives on the access point
	// (HTTP_API_DAGMAN_PATH). Empty means the package default.
	DagmanPath string

	// DagmanEnvironment is extra environment for the DAGMan manager job
	// (HTTP_API_DAGMAN_ENVIRONMENT).
	DagmanEnvironment map[string]string

	// InteractiveRequirements is an optional ClassAd expression ANDed into
	// the interactive terminal job's Requirements.
	//
	// It exists because a terminal is only useful if it can be attached to,
	// and whether that works is a property of the machine. condor_ssh_to_job
	// enters the job's namespace with setns, which fails when the container
	// runtime made that namespace as root and HTCondor is not root -- the
	// job runs, and every attempt to open a shell on it fails. Constraining
	// where these jobs land is the only lever the submitter has.
	//
	// Operator-only configuration, never caller-supplied: it is an
	// expression, and a submitter who could set it could widen their own
	// match rather than narrow it.
	InteractiveRequirements string

	// Build is the site's container-build configuration, handed to the
	// MCP server's build_container tool. See mcpserver.BuildConfig.
	Build mcpserver.BuildConfig
	// DBMirrorTokenSubject overrides the identity the htcondordb token
	// asserts, default "condor@<trust domain>". Configure via
	// HTTP_API_DBMIRROR_TOKEN_SUBJECT.
	DBMirrorTokenSubject string

	// SubmitFileDefaults are submit-file lines applied to every
	// submission ONLY where the submit file is silent, so a user who
	// sets the same command keeps their own value. Configure via
	// HTTP_API_SUBMIT_FILE_DEFAULTS.
	SubmitFileDefaults string
	// SubmitFileOverrides are submit-file lines applied to every
	// submission that WIN over whatever the submit file says. Use for
	// requirements that are not the user's to opt out of -- an access
	// point that rejects a `log =` outside the home directory, say.
	// Configure via HTTP_API_SUBMIT_FILE_OVERRIDES.
	//
	// Same trust model as InteractiveExtraSubmit: operator-only config,
	// spliced in verbatim.
	SubmitFileOverrides string
	UserHeader          string // HTTP header to extract username from (optional)
	// UserHeaderTrustedProxies is a list of CIDRs from which UserHeader
	// is honored. When UserHeader is set, this list MUST be non-empty
	// (or UserHeaderTrustAnyUnsafe must be true) — otherwise the
	// header is silently ignored, treating the request as
	// unauthenticated. Configure via HTTP_API_USER_HEADER_TRUSTED_PROXIES
	// (comma-separated CIDRs, e.g. "127.0.0.1/32,::1/128,10.0.0.0/8")
	// or programmatically.
	UserHeaderTrustedProxies []string

	// TrustedProxies lists CIDRs (or bare addresses) whose X-Forwarded-For
	// and X-Real-IP headers are honoured for access logging. Empty means
	// no forwarded header is believed and the peer address is logged.
	// Configure via HTTP_API_TRUSTED_PROXIES.
	TrustedProxies []string
	// UserHeaderTrustAnyUnsafe disables the trusted-proxy check and
	// accepts UserHeader from any source. This is the demo / test
	// mode only — it is unsafe in any production deployment because
	// it lets any client spoof identity by setting the header.
	// Configure via HTTP_API_USER_HEADER_TRUST_ANY=1 (loud warning
	// at startup).
	UserHeaderTrustAnyUnsafe bool
	SigningKeyPath           string // Path to token signing key (optional, for token generation)
	TrustDomain              string // Trust domain for token issuer (optional; only used if UserHeader is set)
	UIDDomain                string // UID domain for generated token username (optional; only used if UserHeader is set)
	HTTPBaseURL              string // Base URL for HTTP API (e.g., "http://localhost:8080") for generating file download links in MCP responses

	// MCPBaseURL is the public base URL of the MCP listener, when MCP has
	// its own port and that port is published under a different origin
	// than the web UI. Empty means MCP is reached at the same base URL as
	// everything else, which is true whenever the ports are combined and
	// whenever a split is only local.
	//
	// It exists for one attribute: RFC 9728 requires the protected-resource
	// document to name the resource the client asked about, and a client
	// that reaches MCP on another origin asked about that origin. Naming
	// the web UI's instead makes the document one the client must reject.
	MCPBaseURL string
	// CCB decides how to reach a daemon behind a Condor Connection Broker:
	// on an inbound path of this server's own (see sharedportrouter), or by
	// having the broker relay. Set once by the operator and applied to every
	// surface that reaches into a running job -- a shell, a session, tailing
	// output -- so two of them cannot disagree. Nil keeps cedar's default,
	// which only works on a host the execute nodes can reach.
	CCB           *htcondor.CCBDialer
	TLSCACertFile string              // Path to TLS CA certificate file (optional, for trusting self-signed certs)
	Collector     *htcondor.Collector // Collector for metrics (optional)

	// htcondordb mirror routing. Empty/false is the default: discover
	// whatever htcondordb advertises to the collector and use it as an
	// optional accelerator. See dbmirror.Options for what each does and
	// when an operator needs it.
	DBMirrorName     string // HTTP_API_DBMIRROR_NAME: pin routing to this mirror
	DBMirrorAddress  string // HTTP_API_DBMIRROR_ADDRESS: dial this sinful instead of the advertised one
	DBMirrorRequired bool   // HTTP_API_DBMIRROR_REQUIRED: fail rather than fall back to the schedd

	JobQueueLogPath string        // schedd job_queue.log to mirror for /api/v1/jobs/watch (optional)
	EnableMetrics   bool          // Enable /metrics endpoint (default: true if Collector is set)
	MetricsCacheTTL time.Duration // Metrics cache TTL (default: 10s)
	// MetricsPublic disables the API-key auth gate on /metrics. Use
	// only when network ACLs already isolate the endpoint (e.g. a
	// private listening address or a sidecar proxy). Default: false
	// — an admin must mint an API key with the `metrics` scope and
	// configure Prometheus to send it as a Bearer token.
	MetricsPublic bool
	Logger        *logging.Logger // Logger instance (optional, creates default if nil)
	EnableMCP     bool            // Enable MCP endpoints with OAuth2 (default: false)

	// DBPath is the unified SQLite database file backing OAuth2/MCP
	// storage, the embedded IDP, browser sessions, and user-saved
	// batch-submission templates. Defaults to LOCAL_DIR/htcondor-api.db
	// (or /var/lib/condor/htcondor-api.db when LOCAL_DIR is unset).
	// Configure via HTTP_API_DB_PATH.
	DBPath string

	// KEKFilePath is the path to a file holding the master Key
	// Encryption Key used to envelope-encrypt long-lived secrets in
	// the application database (the OAuth2 / IDP issuer's RSA
	// signing key, fosite's HMAC GlobalSecret). Configured via
	// HTTP_API_KEK_FILE — the FILE PATH lives in HTCondor config,
	// the KEY BYTES never do (HTCondor treats config values as
	// public).
	//
	// Empty disables encryption: secrets are stored in plaintext as
	// before. When set, the file must contain exactly 32 raw bytes
	// or a 32-byte hex string and must be 0600/0400. See
	// httpserver/appdb/seal for the design.
	KEKFilePath string

	// OAuth2DBPath is a deprecated alias for DBPath kept so existing
	// in-process embedders (and the test suite) keep compiling without
	// a wholesale rename. NewHandler honors it only when DBPath is
	// empty. The cmd-line wrapper does NOT bridge HTTP_API_OAUTH2_DB_PATH
	// into this field — pointing the unified DB at a pre-unification
	// oauth.db is a guaranteed crash-loop (the legacy schema conflicts
	// with goose 0001_init.sql), and the wrapper logs a deprecation
	// warning instead. New code should set DBPath.
	OAuth2DBPath string
	OAuth2Issuer string // OAuth2 issuer URL (default: listen address)
	// MCPCIMDEnabled resolves an https:// MCP client_id as a Client ID Metadata
	// Document (a public client) instead of requiring DCR. MCPCIMDAllowedHosts,
	// when set, restricts which hosts such a client_id may point at.
	MCPCIMDEnabled      bool
	MCPCIMDAllowedHosts []string
	// MCPTokenExchangeIssuers is the JSON list of trusted external issuers for
	// RFC 8693 token exchange (HTTP_API_MCP_TOKEN_EXCHANGE_ISSUERS). Empty = off.
	MCPTokenExchangeIssuers string
	OAuth2ClientID          string   // OAuth2 client ID for SSO (optional)
	OAuth2ClientSecret      string   // OAuth2 client secret for SSO (optional)
	OAuth2AuthURL           string   // OAuth2 authorization URL for SSO (optional)
	OAuth2TokenURL          string   // OAuth2 token URL for SSO (optional)
	OAuth2RedirectURL       string   // OAuth2 redirect URL for SSO (optional)
	OAuth2UserInfoURL       string   // OAuth2 user info endpoint for SSO (optional)
	OAuth2Scopes            []string // OAuth2 scopes to request (default: ["openid", "profile", "email"])
	OAuth2UsernameClaim     string   // Claim name for username in token (default: "sub")
	OAuth2GroupsClaim       string   // Claim name for groups in user info (default: "groups")
	// OAuth2Requirements is a ClassAd expression evaluated against the
	// token's claims at login. Empty means no policy.
	OAuth2Requirements string

	// IdentityMapStrategies is the ordered list of ways to turn an OIDC
	// subject into a local account -- "gecos", "username", or both, as in
	// "gecos,username". Empty disables mapping entirely. When set, group
	// membership comes from the system rather than the token, and a
	// caller that maps to no single account is refused a session.
	IdentityMapStrategies []idmap.Strategy
	// IdentityGroupSources lists where group membership comes from, as
	// parsed specs: "system" and/or "file:<path>". Empty keeps the
	// token's groups claim, which is what a container wants because it
	// holds no account database to read. Independent of
	// IdentityMapStrategies: a deployment may want either, both, or
	// neither. Several sources are unioned, the way glibc merges NSS
	// services, so a site can carry directory groups and hand-maintained
	// ones at once.
	IdentityGroupSources []string
	// IdentityMapPasswdFile reads accounts from this file instead of
	// /etc/passwd. Empty means /etc/passwd, which is the only account
	// source that enumerates: the GECOS index cannot list accounts that
	// live only in a directory. Accounts it does map are still verified
	// against the live database, directory included.
	IdentityMapPasswdFile string
	// IdentityMapTTL is how long the GECOS index and the group lookups
	// are reused. Zero means five minutes.
	IdentityMapTTL time.Duration

	// IdentityMapStripDomain also tries the local part of a scoped
	// subject -- "bockelman@wisc.edu" as "bockelman". Only sound where
	// something else constrains which identity providers may log in,
	// since the local part is not unique across domains.
	IdentityMapStripDomain bool
	// OAuth2AccessTokenLifespan is how long an access token issued by the embedded
	// MCP issuer is valid. Defaults to 1 hour if zero.
	OAuth2AccessTokenLifespan time.Duration
	// OAuth2RefreshTokenLifespan is how long a refresh token issued by the embedded
	// MCP issuer is valid. Defaults to 30 days if zero. Must be >= OAuth2AccessTokenLifespan;
	// otherwise refresh grants will fail before the access token expires (see PelicanPlatform/pelican#3389).
	OAuth2RefreshTokenLifespan time.Duration
	// OAuth2MaxGrantLifetime caps the total age of a grant, measured from
	// the consent that created it, regardless of how often it is
	// refreshed. Defaults to DefaultMaxGrantLifetime (30 days) if zero.
	//
	// This is distinct from OAuth2RefreshTokenLifespan, which every
	// refresh resets: without a cap, a client that refreshes more often
	// than the refresh lifespan holds its access indefinitely, so removing
	// a user's entitlement never expires anything. The cap is the backstop
	// that bounds that exposure even when no revocation oracle notices the
	// removal. See reauthorizeRefreshGrant.
	//
	// Configurable via HTTP_API_OAUTH2_MAX_GRANT_LIFETIME.
	OAuth2MaxGrantLifetime time.Duration
	// OAuth2RevocationOracles names the oracles consulted on every refresh
	// grant to decide whether the user is still entitled to what they hold.
	// Recognized values:
	//
	//   "schedd-userrec" — honor `condor_qusers -disable <user>`. Reads the
	//       schedd's per-user record (READ authorization) and revokes the
	//       grant when it says Enabled=false, surfacing DisableReason.
	//       Cheap, and acts only on an explicit administrative decision.
	//   "schedd-acl"     — probe the schedd's ALLOW_READ / ALLOW_WRITE ACLs
	//       with DC_SEC_QUERY and strip scopes it refuses. Costs up to two
	//       extra schedd round trips per refresh and tells you nothing in a
	//       pool whose ACLs are wildcards, so it is opt-in.
	//
	// Nil selects the default set (schedd-userrec). An explicitly empty,
	// non-nil slice disables all oracles; the grant lifetime cap still
	// applies. Unrecognized names are logged and ignored.
	//
	// Configurable via HTTP_API_OAUTH2_REVOCATION_ORACLES, where the
	// literal "none" is the spelling for the empty set — a config file
	// cannot express nil-versus-empty on its own.
	OAuth2RevocationOracles []string
	// SuperuserGroup gates superuser mode, in which an administrator acts
	// on another user's jobs as that user. Empty (the default) disables it.
	//
	// Intentionally separate from WebUIAdminGroup. That group means "may
	// read the admin pages"; this one means "may act as anyone on this
	// access point", a categorically larger privilege that deserves its own
	// decision and its own off switch.
	//
	// Superuser mode additionally requires a pool signing key: without one
	// this server cannot mint the credential it would act under, so the
	// feature stays off however this is set.
	//
	// Configurable via HTTP_API_SUPERUSER_GROUP.
	SuperuserGroup string
	// SuperuserRefreshInterval is how often the schedd's QUEUE_SUPER_USERS
	// set is re-read. Zero selects defaultSuperuserRefresh.
	SuperuserRefreshInterval time.Duration
	// SuperuserFallbackIdentity is the identity used when the operator is
	// not themselves a usable queue superuser. Empty selects
	// "condor@$(UID_DOMAIN)".
	//
	// That default suits a schedd running as the condor user, where
	// real_owner_is_condor matches the daemon's own OS user. It does NOT
	// suit a personal condor: there personal_condor is true, that branch is
	// disabled, and the equivalent identity is the user the pool runs as.
	// Deployments that run the schedd as something else need this knob, and
	// so does any test that wants to exercise the fallback without root.
	//
	// Configurable via HTTP_API_SUPERUSER_FALLBACK_IDENTITY.
	SuperuserFallbackIdentity string
	// SuperuserArmTTL is how long superuser mode stays on before disarming
	// itself. Zero selects defaultSuperuserArmTTL.
	SuperuserArmTTL time.Duration
	MCPAccessGroup  string // Group required for any MCP access (empty = all authenticated)
	MCPReadGroup    string // Group required for read operations (empty = all have read)
	MCPWriteGroup   string // Group required for write operations (empty = all have write)
	// MCPAdminGroup grants the mcp:admin scope -- reading every user's
	// jobs through MCP. Empty disables it: unlike MCPReadGroup and
	// MCPWriteGroup, an empty value here grants the privilege to NOBODY
	// rather than to everybody. HTTP_API_MCP_ADMIN_GROUP.
	MCPAdminGroup string
	// MCPSuperuserGroup grants the mcp:superuser scope -- changing
	// another user's jobs (remove, hold, release, edit). Empty disables.
	//
	// Separate from MCPAdminGroup for the same reason SuperuserGroup is
	// separate from WebUIAdminGroup: seeing every job and being able to
	// remove every job are different privileges, and the second deserves
	// its own decision and its own off switch.
	// HTTP_API_MCP_SUPERUSER_GROUP.
	MCPSuperuserGroup string
	MCPInstructions   string // Server-level instructions provided to all MCP agents (e.g., AP-specific guidance)
	// MCPDisabledTools (HTTP_API_MCP_DISABLED_TOOLS) names tools this site cannot
	// offer, as path.Match patterns separated by commas or whitespace.
	MCPDisabledTools string
	// MCPSkillsReloadInterval is how often to re-read MCPSkillsDir.
	MCPSkillsReloadInterval time.Duration
	// MCPSkillsDir is a directory of site-authored Markdown skills to
	// publish to agents. Empty disables the feature.
	MCPSkillsDir string
	// MCPAdminUsers lists authenticated subjects that MCP tool
	// dispatch treats as admins — most importantly they are exempt
	// from the owner-scope wrapper, so they can query and act on other
	// users' jobs. Match is exact against the authenticated actor
	// (typically "user@uid.domain"). Empty = no admins (default).
	MCPAdminUsers   []string
	WebUIAdminGroup string // Group(s) required for Web UI admin pages (empty disables admin UI). Comma-separated; HTTP_API_WEBUI_ADMIN_GROUP.

	// WebUIAccessGroup gates logging in to the web interface, separately
	// from MCP. Comma-separated; HTTP_API_WEBUI_ACCESS_GROUP. Empty falls
	// back to MCPAccessGroup.
	WebUIAccessGroup string
	EnableIDP        bool // Enable built-in IDP (always enabled in demo mode)
	// SpoolBufferDir is where cluster-wide uploads buffer their tar.
	// Empty selects the system temp directory.
	SpoolBufferDir string
	// SeedDemoUser additionally seeds a second, non-admin IDP account
	// ("user") alongside "admin", printing its generated password the
	// same way.
	//
	// Deliberately NOT keyed on EnableIDP: the built-in IDP can be turned
	// on in a real deployment with HTTP_API_ENABLE_IDP, and seeding a
	// second standing account there — with its password on stdout — is
	// not something an operator asked for. Only -demo sets this.
	//
	// It exists so tests have two identities to check authorization
	// boundaries with: an admin, and someone who is not.
	SeedDemoUser bool
	// IDPDBPath is deprecated; the IDP shares the unified DBPath.
	// Retained as an unused field so existing callers keep compiling
	// during the transition.
	IDPDBPath string //nolint:unused // kept for back-compat; ignored by NewHandler.
	IDPIssuer string // IDP issuer URL (default: listen address)
	// IDPAccessTokenLifespan / IDPRefreshTokenLifespan: see OAuth2*Lifespan above. Zero
	// uses the same defaults (1h / 30d).
	IDPAccessTokenLifespan  time.Duration
	IDPRefreshTokenLifespan time.Duration
	SessionTTL              time.Duration  // HTTP session TTL (default: 24h)
	HTCondorConfig          *config.Config // HTCondor configuration (optional, used for LOCAL_DIR default)
	// PingInterval is the cadence of the periodic collector/schedd ping
	// that feeds /readyz. Zero or negative disables it, which is what a
	// deployment with no local HTCondor credential wants: the ping has
	// nothing to authenticate with there and can only fail. There is no
	// implicit default — the shipped daemon sets this from
	// HTTP_API_PING_INTERVAL (default 1m, 0 to disable).
	PingInterval time.Duration
	// MCPWatchMaxWait caps how long the MCP watch_jobs tool may block
	// in-call before returning (HTTP_API_MCP_WATCH_MAX_WAIT). Keep it
	// under the gateway/proxy timeout in front of this daemon: a block
	// that outlives it loses the response carrying the watch id. Zero
	// uses the mcpserver default.
	MCPWatchMaxWait time.Duration
	// CreddAddress pins the credd to talk to (HTTP_API_CREDD_ADDRESS),
	// overriding discovery. Needed where a schedd does not advertise its
	// credd; otherwise the credd is read from the schedd itself.
	CreddAddress string
	// RequiredCredentials names the OAuth service credentials that must
	// exist before a job may be submitted (HTTP_API_REQUIRED_CREDENTIALS).
	// Some access points hold every job submitted without them. Each submit
	// path creates a placeholder for any that is missing; see
	// required_creds.go.
	RequiredCredentials []string
	// UpstreamRefresh is HTTP_API_UPSTREAM_REFRESH. See Config.
	UpstreamRefresh string

	// MCPUseSDKTransport serves /mcp with the upstream MCP SDK's transport
	// instead of the hand-rolled JSON-RPC handler. HTTP_API_MCP_TRANSPORT.
	MCPUseSDKTransport bool

	// TokenRetention is HTTP_API_TOKEN_RETENTION. See Config.
	TokenRetention string

	// MCPMaxRequestDuration is the hard stop on an MCP request that is
	// still making progress (HTTP_API_MCP_MAX_REQUEST_DURATION). While a
	// request runs, its write deadline is moved forward rather than being
	// the server-wide HTTP_API_WRITE_TIMEOUT, so a deliberately waiting
	// tool is bounded by this instead. Zero uses
	// DefaultMCPMaxRequestDuration.
	MCPMaxRequestDuration time.Duration
	StreamBufferSize      int                  // Buffer size for streaming queries (default: 100)
	StreamWriteTimeout    time.Duration        // Write timeout for streaming queries (default: 5s)
	Token                 string               // Token for daemon authentication (optional)
	Credd                 htcondor.CreddClient // Optional credd client; defaults to in-memory implementation
	// Placementd is an optional condor_placementd client. Nil means
	// "discover one", and a failed discovery simply leaves the
	// placement endpoints disabled. Tests inject a fake here.
	Placementd htcondor.PlacementdClient

	// LLMAPIKeyFile is the path to a file holding the Anthropic API
	// key used by the chat endpoint at /api/v1/chat. Empty disables
	// the chat feature. The bytes never live in HTCondor config —
	// only this file path does.
	LLMAPIKeyFile string
	// LLMAPIURL overrides the upstream Anthropic Messages endpoint.
	// Use to point at a self-hosted LLM gateway / cache. Empty falls
	// back to chat.DefaultAnthropicURL.
	LLMAPIURL string
	// LLMModel overrides the default Anthropic model id. Empty falls
	// back to chat.DefaultAnthropicModel.
	LLMModel string

	// LLMOperatorInstructionsFile is the path to a file containing
	// extra system-prompt rules the operator wants appended to every
	// chat turn (e.g. "users in this pool may not request more than
	// 64 GiB of memory; suggest fewer if asked"). Empty path = no
	// addendum.
	//
	// File mode is enforced 0600/0400 on load — same rationale as the
	// API-key file: the operator may put policy text here that they
	// don't want every local user to read. Loaded once at startup; a
	// hot-reload would need a server restart.
	LLMOperatorInstructionsFile string

	// JupyterWorkDir is where the embedded helper binary (materialized
	// from package jupyterhelperbin) and per-instance scratch artifacts
	// (token files) are staged. Files persist for the lifetime of the
	// job since HTCondor reads transfer_input_files at job-startup time.
	// Defaults to <os.TempDir>/htcondor-api-jupyter.
	JupyterWorkDir string
	// JupyterMaxLifetimeSec / JupyterKernelIdleSec bound a JupyterLab
	// session; see handlers_jupyter.go. Zero disables that limit.
	JupyterMaxLifetimeSec int
	JupyterKernelIdleSec  int

	// TemplateGlobalPath is an optional YAML file with operator-curated
	// batch-submission templates. Empty disables. Built-in templates
	// always ship.
	TemplateGlobalPath string

	// TemplateUserStoreDBPath is deprecated; the templates store now
	// shares the unified DBPath. Retained so existing callers
	// keep compiling; ignored by NewHandler.
	TemplateUserStoreDBPath string //nolint:unused // kept for back-compat; ignored by NewHandler.
}

HandlerConfig holds handler configuration

type HistoryListResponse

type HistoryListResponse struct {
	Ads        []*classad.ClassAd `json:"ads"`
	Source     string             `json:"source,omitempty"`
	SourceNote string             `json:"source_note,omitempty"`
}

HistoryListResponse represents a history listing response. Source and SourceNote name the backend that answered ("htcondordb" when a synchronized mirror served it, absent for the schedd) so a caller can tell how fresh the records are.

type HoldReasonCount added in v0.14.1

type HoldReasonCount struct {
	Code  int64  `json:"code"`
	Label string `json:"label"`
	Count int    `json:"count"`
	// Example is one hold reason string with this code, because the code
	// says the category and the message says which file or which host.
	Example string `json:"example,omitempty"`
}

HoldReasonCount is one row of the hold breakdown.

type IDPProvider

type IDPProvider struct {
	// contains filtered or unexported fields
}

IDPProvider manages OAuth2 operations for the built-in IDP

func NewIDPProvider

func NewIDPProvider(opts IDPProviderOptions) (*IDPProvider, error)

NewIDPProvider creates a new IDP provider with SQLite storage. Both AccessTokenLifespan and RefreshTokenLifespan in opts must be > 0; otherwise an error is returned. See OAuth2ProviderOptions for the rationale.

func (*IDPProvider) Close

func (p *IDPProvider) Close() error

Close is now a no-op: the IDP provider does not own the underlying *sql.DB anymore. The Handler that opened the unified app DB is responsible for closing it on shutdown.

func (*IDPProvider) GetProvider

func (p *IDPProvider) GetProvider() fosite.OAuth2Provider

GetProvider returns the underlying fosite OAuth2Provider

func (*IDPProvider) GetStorage

func (p *IDPProvider) GetStorage() *IDPStorage

GetStorage returns the IDP storage

func (*IDPProvider) GetStrategy

func (p *IDPProvider) GetStrategy() *compose.CommonStrategy

GetStrategy returns the OAuth2 strategy

func (*IDPProvider) UpdateIssuer

func (p *IDPProvider) UpdateIssuer(issuer string)

UpdateIssuer updates the issuer URL in the OAuth2 config

type IDPProviderOptions

type IDPProviderOptions struct {
	DB                   *sql.DB
	Issuer               string
	AccessTokenLifespan  time.Duration
	RefreshTokenLifespan time.Duration
	// Sealer envelope-encrypts the IDP's RSA private key + HMAC
	// GlobalSecret. See OAuth2ProviderOptions.Sealer.
	Sealer *seal.Sealer
}

IDPProviderOptions configures lifespans and other tunables for the IDP provider. DB is the unified application database (see appdb); the provider does not own its lifecycle.

type IDPStorage

type IDPStorage struct {
	// contains filtered or unexported fields
}

IDPStorage implements fosite storage interfaces using the unified application database. The IDP's tables (idp_*) live alongside the OAuth2/MCP tables in the same SQLite file managed by appdb.

See OAuth2Storage for the sealer field's role.

func NewIDPStorage

func NewIDPStorage(db *sql.DB) *IDPStorage

NewIDPStorage wraps an already-opened, already-migrated DB. Schema is owned by the appdb migrations; this struct only holds the query helpers. The caller retains DB ownership.

func (*IDPStorage) AuthenticateUser

func (s *IDPStorage) AuthenticateUser(ctx context.Context, username, password string) error

AuthenticateUser verifies username and password

func (*IDPStorage) ClientAssertionJWTValid

func (s *IDPStorage) ClientAssertionJWTValid(ctx context.Context, jti string) error

ClientAssertionJWTValid implements fosite.ClientAssertionJWTValid interface

func (*IDPStorage) CreateAccessTokenSession

func (s *IDPStorage) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) error

CreateAccessTokenSession stores an access token session

func (*IDPStorage) CreateAuthorizeCodeSession

func (s *IDPStorage) CreateAuthorizeCodeSession(ctx context.Context, signature string, request fosite.Requester) error

CreateAuthorizeCodeSession stores an authorization code session

func (*IDPStorage) CreateClient

func (s *IDPStorage) CreateClient(ctx context.Context, client *fosite.DefaultClient) error

CreateClient creates a new OAuth2 client

func (*IDPStorage) CreateOpenIDConnectSession

func (s *IDPStorage) CreateOpenIDConnectSession(ctx context.Context, signature string, requester fosite.Requester) error

CreateOpenIDConnectSession implements openid.OpenIDConnectRequestStorage interface

func (*IDPStorage) CreatePKCERequestSession

func (s *IDPStorage) CreatePKCERequestSession(ctx context.Context, signature string, request fosite.Requester) error

CreatePKCERequestSession stores a PKCE request session

func (*IDPStorage) CreateRefreshTokenSession

func (s *IDPStorage) CreateRefreshTokenSession(ctx context.Context, signature string, _ string, request fosite.Requester) error

CreateRefreshTokenSession stores a refresh token session

func (*IDPStorage) CreateSession

func (s *IDPStorage) CreateSession(ctx context.Context, username string) (string, error)

CreateSession creates a new session for the given username

func (*IDPStorage) CreateUser

func (s *IDPStorage) CreateUser(ctx context.Context, username, password, state string) error

CreateUser creates a new user with hashed password and specified state

func (*IDPStorage) DeleteAccessTokenSession

func (s *IDPStorage) DeleteAccessTokenSession(ctx context.Context, signature string) error

DeleteAccessTokenSession deletes an access token session

func (*IDPStorage) DeleteOpenIDConnectSession

func (s *IDPStorage) DeleteOpenIDConnectSession(ctx context.Context, signature string) error

DeleteOpenIDConnectSession implements openid.OpenIDConnectRequestStorage interface

func (*IDPStorage) DeletePKCERequestSession

func (s *IDPStorage) DeletePKCERequestSession(ctx context.Context, signature string) error

DeletePKCERequestSession deletes a PKCE request session

func (*IDPStorage) DeleteRefreshTokenSession

func (s *IDPStorage) DeleteRefreshTokenSession(ctx context.Context, signature string) error

DeleteRefreshTokenSession deletes a refresh token session

func (*IDPStorage) DeleteSession

func (s *IDPStorage) DeleteSession(ctx context.Context, sessionID string) error

DeleteSession deletes a session

func (*IDPStorage) GetAccessTokenSession

func (s *IDPStorage) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error)

GetAccessTokenSession retrieves an access token session

func (*IDPStorage) GetAuthorizeCodeSession

func (s *IDPStorage) GetAuthorizeCodeSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error)

GetAuthorizeCodeSession retrieves an authorization code session

func (*IDPStorage) GetClient

func (s *IDPStorage) GetClient(ctx context.Context, clientID string) (fosite.Client, error)

GetClient retrieves a client by ID

func (*IDPStorage) GetOpenIDConnectSession

func (s *IDPStorage) GetOpenIDConnectSession(ctx context.Context, signature string, requester fosite.Requester) (fosite.Requester, error)

GetOpenIDConnectSession implements openid.OpenIDConnectRequestStorage interface

func (*IDPStorage) GetPKCERequestSession

func (s *IDPStorage) GetPKCERequestSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error)

GetPKCERequestSession retrieves a PKCE request session

func (*IDPStorage) GetRefreshTokenSession

func (s *IDPStorage) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error)

GetRefreshTokenSession retrieves a refresh token session

func (*IDPStorage) GetSession

func (s *IDPStorage) GetSession(ctx context.Context, sessionID string) (string, error)

GetSession retrieves the username for a given session ID

func (*IDPStorage) GetUserState

func (s *IDPStorage) GetUserState(ctx context.Context, username string) (string, error)

GetUserState retrieves the state of a user

func (*IDPStorage) InvalidateAuthorizeCodeSession

func (s *IDPStorage) InvalidateAuthorizeCodeSession(ctx context.Context, signature string) error

InvalidateAuthorizeCodeSession invalidates an authorization code

func (*IDPStorage) LoadHMACSecret

func (s *IDPStorage) LoadHMACSecret(ctx context.Context) ([]byte, error)

LoadHMACSecret loads the HMAC secret. See OAuth2Storage.LoadHMACSecret.

func (*IDPStorage) LoadRSAKey

func (s *IDPStorage) LoadRSAKey(ctx context.Context) (string, error)

LoadRSAKey loads the RSA private key. See OAuth2Storage.LoadRSAKey.

func (*IDPStorage) RevokeAccessToken

func (s *IDPStorage) RevokeAccessToken(ctx context.Context, requestID string) error

RevokeAccessToken revokes an access token

func (*IDPStorage) RevokeAllForSubject added in v0.13.0

func (s *IDPStorage) RevokeAllForSubject(ctx context.Context, subject string) (int64, error)

RevokeAllForSubject deactivates every IDP access and refresh token belonging to one subject. See OAuth2Storage.RevokeAllForSubject.

func (*IDPStorage) RevokeRefreshToken

func (s *IDPStorage) RevokeRefreshToken(ctx context.Context, requestID string) error

RevokeRefreshToken revokes a refresh token

func (*IDPStorage) RevokeRefreshTokenMaybeGracePeriod

func (s *IDPStorage) RevokeRefreshTokenMaybeGracePeriod(ctx context.Context, requestID string, _ string) error

RevokeRefreshTokenMaybeGracePeriod implements fosite.TokenRevocationStorage interface

func (*IDPStorage) RotateRefreshToken

func (s *IDPStorage) RotateRefreshToken(ctx context.Context, requestID string, _ string) error

RotateRefreshToken revokes the refresh token and its associated access token for the request (required by fosite's RefreshTokenStorage as of v0.49).

func (*IDPStorage) SaveHMACSecret

func (s *IDPStorage) SaveHMACSecret(ctx context.Context, secret []byte) error

SaveHMACSecret stores the HMAC secret. See OAuth2Storage.SaveHMACSecret.

func (*IDPStorage) SaveRSAKey

func (s *IDPStorage) SaveRSAKey(ctx context.Context, privateKeyPEM string) error

SaveRSAKey stores the RSA private key. Mirrors OAuth2Storage's implementation — see SaveRSAKey there for the encryption rationale.

func (*IDPStorage) SetClientAssertionJWT

func (s *IDPStorage) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error

SetClientAssertionJWT implements fosite.SetClientAssertionJWT interface

func (*IDPStorage) SetSealer

func (s *IDPStorage) SetSealer(sealer *seal.Sealer)

SetSealer enables envelope encryption for the long-lived secret columns (RSA private key, HMAC secret). See OAuth2Storage.SetSealer.

func (*IDPStorage) UserExists

func (s *IDPStorage) UserExists(ctx context.Context, username string) (bool, error)

UserExists checks if a user exists

type Impersonation added in v0.13.0

type Impersonation struct {
	// Actor is the authenticated human who armed superuser mode.
	Actor string
	// Target is the job owner being acted for, derived from the job rather
	// than supplied by the caller.
	Target string
	// Identity is what this server authenticates to the schedd as. Either
	// Actor (when they are themselves a queue superuser) or the shared
	// fallback.
	Identity string
	// ActorIsSuperUser records which of those it was. When true the schedd's
	// own log names the human; when false the schedd only ever sees the
	// shared identity and this server's audit record is the only place the
	// actor appears.
	ActorIsSuperUser bool
}

Impersonation describes one superuser action: who asked for it, whose job it is, and which identity the server will present to the schedd.

func (Impersonation) Reason added in v0.13.0

func (i Impersonation) Reason(what string) string

Reason renders the actor and target into a string suitable for a HoldReason / RemoveReason / ReleaseReason.

The schedd appends "(by user <authenticated identity>)" to whatever reason it is given -- see actOnJobs in schedd.cpp -- so when the actor is a queue superuser the job ad ends up naming them twice, from two independent sources. When they are not, the schedd can only append the shared identity, and this prefix is the ONLY record in the job ad of which human acted. That is why the actor goes in the text rather than being left to the schedd.

The result lands in the job ad, so it outlives this server's logs, follows the job into history, and is visible to the job's owner -- who is entitled to know that somebody else touched their job, and which somebody.

type InteractiveCreateTerminalRequest

type InteractiveCreateTerminalRequest struct {
	Cpus     int `json:"cpus,omitempty"`
	MemoryMB int `json:"memory_mb,omitempty"`
	DiskMB   int `json:"disk_mb,omitempty"`

	// GPU fields. Mirrored verbatim into request_gpus and the
	// gpus_minimum_* / cuda_version / require_gpus submit lines.
	// Gpus == 0 disables the entire GPU section in the submit file.
	Gpus                  int    `json:"gpus,omitempty"`
	GpusMinimumCapability string `json:"gpus_minimum_capability,omitempty"`
	GpusMinimumMemory     int    `json:"gpus_minimum_memory,omitempty"`
	GpusMinimumRuntime    string `json:"gpus_minimum_runtime,omitempty"`
	CudaVersion           string `json:"cuda_version,omitempty"`
	RequireGpus           string `json:"require_gpus,omitempty"`

	// SubmitLines are extra submit commands the user typed in the launch
	// form (e.g. "+ProjectName = ...", "environment = ..."). Untrusted:
	// validated with interactive.ValidateCallerSubmitLines, which rejects
	// anything that would redefine the session (executable, universe,
	// queue, ...). Merged before the operator's block so operator policy
	// still wins.
	SubmitLines string `json:"submit_lines,omitempty"`
}

InteractiveCreateTerminalRequest is the optional JSON body of POST /api/v1/interactive/terminal. All fields are optional; the server fills sensible defaults.

type InteractiveCreateTerminalResponse

type InteractiveCreateTerminalResponse struct {
	InstanceID string `json:"instance_id"`
	ClusterID  int    `json:"cluster_id"`
	ProcID     int    `json:"proc_id"`
	JobID      string `json:"job_id"` // "cluster.proc" — convenience for the SPA
	BatchName  string `json:"batch_name"`
}

InteractiveCreateTerminalResponse is the JSON returned on success.

type InteractiveTerminalSummary

type InteractiveTerminalSummary struct {
	InstanceID                   string `json:"instance_id"`
	JobID                        string `json:"job_id"`
	ClusterID                    int    `json:"cluster_id"`
	ProcID                       int    `json:"proc_id"`
	BatchName                    string `json:"batch_name"`
	JobStatus                    int    `json:"job_status"`
	JobCurrentStartExecutingDate int64  `json:"job_current_start_executing_date,omitempty"`
	HoldReasonCode               int    `json:"hold_reason_code,omitempty"`
	HoldReason                   string `json:"hold_reason,omitempty"`
	SubmittedAt                  string `json:"submitted_at,omitempty"` // RFC3339 from QDate
}

InteractiveTerminalSummary is the SPA-facing shape of one terminal session. Returned by GET /api/v1/interactive/terminal.

JobCurrentStartExecutingDate is the schedd's "executable actually started running" timestamp; combined with JobStatus the SPA's shared status module distinguishes "queued" from "transferring input" from "executing".

type IssueSection added in v0.19.0

type IssueSection struct {
	Kind  string `json:"kind"`
	Title string `json:"title"`
	// Total and Users are over the whole section, so a section header
	// can say "4,812 jobs, 11 users" without the reader adding up rows
	// -- and so the numbers do not change when the slider does.
	Total    int              `json:"total"`
	Users    int              `json:"users"`
	Clusters []issues.Cluster `json:"clusters"`
}

IssueSection is one kind of problem: holds, or run attempts that failed.

type IssueTimings added in v0.19.0

type IssueTimings struct {
	// HoldsMs and RunAttemptsMs are the two reads, with what they
	// returned beside them -- a slow read of forty rows and a slow read
	// of forty thousand are different problems.
	HoldsMs       int64 `json:"holds_query_ms"`
	Holds         int   `json:"holds"`
	RunAttemptsMs int64 `json:"run_attempts_query_ms"`
	RunAttempts   int   `json:"run_attempts"`
	// ClusterMs is the grouping: masking, the parse tree, the merge pass
	// and the per-cluster summaries.
	ClusterMs int64 `json:"cluster_ms"`
	// Cached says the reads were not done for this request. Without it a
	// second page load looks fast and hides what the first one cost.
	Cached bool `json:"cached"`
	// AgeSeconds is how old the cached reads are.
	AgeSeconds int64 `json:"age_seconds,omitempty"`
}

IssueTimings splits the cost of one answer.

type IssuesResponse added in v0.19.0

type IssuesResponse struct {
	WindowSeconds int64 `json:"window_seconds"`
	ComputedAt    int64 `json:"computed_at"`
	// Granularity as applied, which may be the clamped form of what was
	// asked for.
	Granularity float64 `json:"granularity"`
	// IncludeEnded says whether run-attempt history was read: without
	// it the page describes what is stuck now, with it what has gone
	// wrong over the window.
	IncludeEnded bool `json:"include_ended"`
	// BucketSeconds is how much time one slice of a cluster's timeline
	// covers, so a caller can label it without re-deriving the window.
	BucketSeconds int64          `json:"bucket_seconds,omitempty"`
	Source        string         `json:"source,omitempty"`
	Truncated     bool           `json:"truncated,omitempty"`
	Notes         []string       `json:"notes,omitempty"`
	Sections      []IssueSection `json:"sections"`
	// Timings is what the answer cost to produce. Returned rather than
	// only logged: this page reads two large tables and then does real
	// work on what comes back, so "it is slow" has three possible
	// answers, and the person who can see the slowness is usually not
	// the person who can read the server's log.
	Timings *IssueTimings `json:"timings,omitempty"`
}

IssuesResponse is the whole page.

type JobActionFunc

type JobActionFunc func(ctx context.Context, constraint, reason string) (*htcondor.JobActionResults, error)

JobActionFunc is a function that performs a job action (hold, release, etc.)

type JobEditRequest

type JobEditRequest struct {
	Attributes map[string]interface{} `json:"attributes"` // Attributes to update
}

JobEditRequest represents a job edit request

type JobListResponse

type JobListResponse struct {
	Jobs []*classad.ClassAd `json:"jobs"`
}

JobListResponse represents a job listing response

type JobLogResponse

type JobLogResponse struct {
	JobID     string          `json:"jobId"`
	Filename  string          `json:"filename"`
	Truncated bool            `json:"truncated"`
	Events    []userlog.Event `json:"events"`
}

JobLogResponse is the JSON shape returned by GET /api/v1/jobs/{id}/log. It mirrors the stdout/stderr endpoints — explicit fetch, no streaming.

type JobSubmitRequest

type JobSubmitRequest struct {
	SubmitFile string `json:"submit_file"` // Submit file content
}

JobSubmitRequest represents a job submission request

type JobSubmitResponse

type JobSubmitResponse struct {
	ClusterID int      `json:"cluster_id"`
	JobIDs    []string `json:"job_ids"` // Array of "cluster.proc" strings
}

JobSubmitResponse represents a job submission response

type JupyterCreateRequest

type JupyterCreateRequest struct {
	// Image is the Docker image to launch. Default
	// quay.io/jupyter/scipy-notebook:latest.
	Image string `json:"image"`
	// Cpus is the requested core count. Default 2.
	Cpus int `json:"cpus"`
	// MemoryMB is the requested RAM in mebibytes. Default 4096.
	MemoryMB int `json:"memory_mb"`
	// DiskMB is the requested scratch disk in mebibytes. Default 4096.
	DiskMB int `json:"disk_mb"`

	// GPU fields. Mirrored verbatim into request_gpus and the
	// gpus_minimum_* / cuda_version / require_gpus submit lines.
	// Gpus == 0 disables the entire GPU section in the submit file.
	Gpus                  int    `json:"gpus,omitempty"`
	GpusMinimumCapability string `json:"gpus_minimum_capability,omitempty"`
	GpusMinimumMemory     int    `json:"gpus_minimum_memory,omitempty"`
	GpusMinimumRuntime    string `json:"gpus_minimum_runtime,omitempty"`
	CudaVersion           string `json:"cuda_version,omitempty"`
	RequireGpus           string `json:"require_gpus,omitempty"`

	// SubmitLines are extra submit commands the user typed in the launch
	// form. Untrusted: validated with interactive.ValidateCallerSubmitLines,
	// which rejects anything that would redefine the job (executable,
	// universe, container_image, queue, ...). Merged before the operator's
	// extras so operator policy still wins.
	SubmitLines string `json:"submit_lines,omitempty"`
}

JupyterCreateRequest is the optional JSON body of POST /jupyter/instances. All fields have sensible defaults so a bare {} is a valid request.

type JupyterCreateResponse

type JupyterCreateResponse struct {
	InstanceID string `json:"instance_id"`
	ClusterID  string `json:"cluster_id"`
	// ProxyPath is where the browser should eventually point its iframe
	// (only useful once the helper has connected back; the SSE stream
	// from /events tells you when).
	ProxyPath string `json:"proxy_path"`
}

JupyterCreateResponse is the JSON returned by POST /jupyter/instances.

type JupyterInstanceSummary

type JupyterInstanceSummary struct {
	InstanceID                   string `json:"instance_id"`
	ClusterID                    string `json:"cluster_id,omitempty"`
	Image                        string `json:"image,omitempty"`
	Owner                        string `json:"owner"`
	CreatedAt                    string `json:"created_at"`
	Connected                    bool   `json:"connected"` // helper has dialed back
	ProxyPath                    string `json:"proxy_path"`
	EventsPath                   string `json:"events_path"`
	JobStatus                    int    `json:"job_status,omitempty"`
	JobCurrentStartExecutingDate int64  `json:"job_current_start_executing_date,omitempty"`
	HoldReasonCode               int    `json:"hold_reason_code,omitempty"`
	HoldReason                   string `json:"hold_reason,omitempty"`
}

JupyterInstanceSummary is the SPA-facing shape returned by both GET /api/v1/jupyter/instances (list) and GET /api/v1/jupyter/instances/{id} (single). The proxy_path is what the iframe should mount; the events_path drives the SSE stream.

The job_* fields are populated from a single bulk schedd query (handleJupyterListInstances) so the list view can run the same status-interpretation logic the detail page uses, without a round-trip per row. Empty when the schedd query failed or the cluster is gone — the SPA falls back to a "loading"/"connected only" view in that case.

type LoginRateLimiter

type LoginRateLimiter struct {
	// contains filtered or unexported fields
}

LoginRateLimiter manages rate limiting for login attempts per IP address

func NewLoginRateLimiter

func NewLoginRateLimiter(r rate.Limit, b int) *LoginRateLimiter

NewLoginRateLimiter creates a new login rate limiter rate: maximum requests per second per IP burst: maximum burst size per IP

func (*LoginRateLimiter) Allow

func (l *LoginRateLimiter) Allow(ip string) bool

Allow checks if a login attempt from the given IP is allowed

type OAuth2Provider

type OAuth2Provider struct {
	// contains filtered or unexported fields
}

OAuth2Provider manages OAuth2 operations

func NewOAuth2Provider

func NewOAuth2Provider(opts OAuth2ProviderOptions) (*OAuth2Provider, error)

NewOAuth2Provider creates a new OAuth2 provider with SQLite storage. Both AccessTokenLifespan and RefreshTokenLifespan in opts must be > 0; otherwise an error is returned. This is intentional: silent fallback to fosite's defaults (1h access, 30d refresh) has bitten downstream projects when callers forget to pass them through, so callers must opt in explicitly.

func (*OAuth2Provider) AuthenticateClient added in v0.14.0

func (p *OAuth2Provider) AuthenticateClient(ctx context.Context, r *http.Request, form url.Values) (fosite.Client, error)

AuthenticateClient authenticates the client on a token request (client_secret_basic / client_secret_post), for custom grant flows that bypass fosite's NewAccessRequest pipeline -- notably RFC 8693 token exchange. The concrete provider from compose.Compose is *fosite.Fosite, which exposes the same client-authentication strategy the standard token endpoint uses.

func (*OAuth2Provider) Close

func (p *OAuth2Provider) Close() error

Close is now a no-op: the OAuth2 provider does not own the underlying *sql.DB anymore. The Handler that opened the unified app DB is responsible for closing it on shutdown. Method retained so callers that defer p.Close() during refactors don't break.

func (*OAuth2Provider) GetProvider

func (p *OAuth2Provider) GetProvider() fosite.OAuth2Provider

GetProvider returns the underlying fosite OAuth2Provider

func (*OAuth2Provider) GetStorage

func (p *OAuth2Provider) GetStorage() *OAuth2Storage

GetStorage returns the OAuth2 storage

func (*OAuth2Provider) GetStrategy

func (p *OAuth2Provider) GetStrategy() *compose.CommonStrategy

GetStrategy returns the OAuth2 strategy

func (*OAuth2Provider) IntrospectAccessToken added in v0.14.0

func (p *OAuth2Provider) IntrospectAccessToken(ctx context.Context, token string) (fosite.AccessRequester, error)

IntrospectAccessToken validates one of our access tokens and returns the requester behind it (subject via GetSession, and the granted scopes), or an error if the token is unknown/expired/revoked. Used by token exchange to bind a subject_token to its authorization. Unlike IntrospectToken it keeps the requester, which is where the granted scopes live.

func (*OAuth2Provider) IntrospectToken

func (p *OAuth2Provider) IntrospectToken(ctx context.Context, token string) (fosite.Session, error)

IntrospectToken validates an access token and returns the session

func (*OAuth2Provider) UpdateIssuer

func (p *OAuth2Provider) UpdateIssuer(issuer string)

UpdateIssuer updates the issuer URL in the configuration This is useful when using port 0 and getting the actual port after server start

type OAuth2ProviderOptions

type OAuth2ProviderOptions struct {
	DB                   *sql.DB
	Issuer               string
	AccessTokenLifespan  time.Duration
	RefreshTokenLifespan time.Duration
	// Sealer envelope-encrypts long-lived secrets in the DB (the
	// issuer's RSA private key, fosite's HMAC GlobalSecret). When
	// non-nil, the storage adapter pulls/pushes ciphertext + wrapped
	// DEK on the corresponding load/save calls. Nil = plaintext.
	Sealer *seal.Sealer

	// CIMDEnabled turns on Client ID Metadata Document resolution: an https://
	// client_id is fetched and treated as a public client (see oauth2_cimd.go).
	CIMDEnabled bool
	// CIMDAllowedHosts optionally restricts which hosts a CIMD client_id may
	// point at; empty means any host (the SSRF guards still apply).
	CIMDAllowedHosts []string
}

OAuth2ProviderOptions configures lifespans and other tunables for the OAuth2 provider. Lifespans must be > 0; callers are expected to validate or default before constructing. DB is the unified application database (see appdb); the provider does not own its lifecycle.

type OAuth2StateEntry

type OAuth2StateEntry struct {
	AuthorizeRequest fosite.AuthorizeRequester
	Timestamp        time.Time
	OriginalURL      string   // Original URL to redirect back to after authentication
	Username         string   // Authenticated username for consent flow
	Groups           []string // User groups for scope filtering in consent flow
}

OAuth2StateEntry represents a stored OAuth2 authorization state

type OAuth2StateStore

type OAuth2StateStore struct {
	// contains filtered or unexported fields
}

OAuth2StateStore manages OAuth2 state parameters for the authorization flow

func NewOAuth2StateStore

func NewOAuth2StateStore() *OAuth2StateStore

NewOAuth2StateStore creates a new OAuth2 state store Call Start() to begin the cleanup goroutine

func (*OAuth2StateStore) GenerateState

func (s *OAuth2StateStore) GenerateState() (string, error)

GenerateState generates a secure random state parameter

func (*OAuth2StateStore) Get

Get retrieves and removes an authorize request for the given state

func (*OAuth2StateStore) GetWithURL

func (s *OAuth2StateStore) GetWithURL(state string) (fosite.AuthorizeRequester, string, bool)

GetWithURL retrieves and removes an authorize request for the given state along with the original URL

func (*OAuth2StateStore) GetWithUsername

func (s *OAuth2StateStore) GetWithUsername(state string) (fosite.AuthorizeRequester, string, []string, bool)

GetWithUsername retrieves an authorize request for the given state along with username and groups (without removing)

func (*OAuth2StateStore) Remove

func (s *OAuth2StateStore) Remove(state string)

Remove removes an entry for the given state

func (*OAuth2StateStore) Start

func (s *OAuth2StateStore) Start(ctx context.Context)

Start begins the cleanup goroutine

func (*OAuth2StateStore) Store

func (s *OAuth2StateStore) Store(state string, ar fosite.AuthorizeRequester)

Store stores an authorize request with the given state

func (*OAuth2StateStore) StoreWithURL

func (s *OAuth2StateStore) StoreWithURL(state string, ar fosite.AuthorizeRequester, originalURL string)

StoreWithURL stores an authorize request with the given state and original URL

func (*OAuth2StateStore) StoreWithUsername

func (s *OAuth2StateStore) StoreWithUsername(state string, ar fosite.AuthorizeRequester, originalURL, username string, groups ...[]string)

StoreWithUsername stores an authorize request with the given state, original URL, and username

func (*OAuth2StateStore) Wait

func (s *OAuth2StateStore) Wait()

Wait waits for the cleanup goroutine to finish

type OAuth2Storage

type OAuth2Storage struct {
	// contains filtered or unexported fields
}

OAuth2Storage implements fosite storage interfaces using the unified application database. The schema is owned by appdb's migrations — this struct is purely a thin set of query helpers around an already- migrated *sql.DB.

The optional `sealer` field is the application's envelope-encryption gate. When non-nil, SaveRSAKey / SaveHMACSecret store ciphertext + wrapped DEK; LoadRSAKey / LoadHMACSecret transparently decrypt rows whose DEK column is populated. When nil, the storage falls back to the pre-encryption plaintext behavior — back-compat for deployments that haven't configured a KEK yet.

func NewOAuth2Storage

func NewOAuth2Storage(db *sql.DB) *OAuth2Storage

NewOAuth2Storage wraps an already-opened DB in the OAuth2 storage helpers. Schema creation is no longer this struct's responsibility — see httpserver/appdb. The caller retains ownership of the DB (don't call Close() here on shutdown).

The returned storage starts in plaintext mode; call SetSealer if a KEK has been loaded.

func (*OAuth2Storage) ApproveDeviceCodeSession

func (s *OAuth2Storage) ApproveDeviceCodeSession(ctx context.Context, userCode string, subject string, session fosite.Session) error

ApproveDeviceCodeSession approves a device code (user authorized the device)

func (*OAuth2Storage) ApproveDeviceCodeSessionWithScopes

func (s *OAuth2Storage) ApproveDeviceCodeSessionWithScopes(ctx context.Context, userCode string, subject string, session fosite.Session, grantedScopes []string) error

ApproveDeviceCodeSessionWithScopes is like ApproveDeviceCodeSession but also overrides the device code's granted_scopes column with the supplied subset. Use this when the consent UI showed the user per-scope checkboxes and the user declined some — the resulting access token must reflect the user-approved intersection, not the originally-requested set.

Pass nil grantedScopes to leave the existing granted_scopes untouched (equivalent to ApproveDeviceCodeSession). Pass an empty (non-nil) slice to record "user explicitly approved zero scopes" — useful as a sentinel; fosite will refuse to mint a token for a no-scope grant, but we want the audit log to show the user's choice.

func (*OAuth2Storage) ClientAssertionJWTValid

func (s *OAuth2Storage) ClientAssertionJWTValid(ctx context.Context, jti string) error

ClientAssertionJWTValid implements fosite.ClientAssertionJWTValid interface This checks if a JWT ID (JTI) has already been used to prevent replay attacks

func (*OAuth2Storage) CreateAccessTokenSession

func (s *OAuth2Storage) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) error

CreateAccessTokenSession stores an access token session

func (*OAuth2Storage) CreateAuthorizeCodeSession

func (s *OAuth2Storage) CreateAuthorizeCodeSession(ctx context.Context, signature string, request fosite.Requester) error

CreateAuthorizeCodeSession stores an authorization code session

func (*OAuth2Storage) CreateClient

func (s *OAuth2Storage) CreateClient(ctx context.Context, client *fosite.DefaultClient) error

CreateClient creates a new OAuth2 client

func (*OAuth2Storage) CreateDeviceCodeSession

func (s *OAuth2Storage) CreateDeviceCodeSession(ctx context.Context, deviceCode string, userCode string, request fosite.Requester, expiresAt time.Time) error

CreateDeviceCodeSession creates a new device code session

func (*OAuth2Storage) CreateOpenIDConnectSession

func (s *OAuth2Storage) CreateOpenIDConnectSession(ctx context.Context, signature string, requester fosite.Requester) error

CreateOpenIDConnectSession implements openid.OpenIDConnectRequestStorage interface

func (*OAuth2Storage) CreatePKCERequestSession

func (s *OAuth2Storage) CreatePKCERequestSession(ctx context.Context, signature string, request fosite.Requester) error

CreatePKCERequestSession stores a PKCE request session

func (*OAuth2Storage) CreateRefreshTokenSession

func (s *OAuth2Storage) CreateRefreshTokenSession(ctx context.Context, signature string, _ string, request fosite.Requester) error

CreateRefreshTokenSession stores a refresh token session. As of fosite v0.49 the signature carries the associated access token signature; we key sessions off the refresh signature and revoke by request ID, so it is not stored.

func (*OAuth2Storage) DeleteAccessTokenSession

func (s *OAuth2Storage) DeleteAccessTokenSession(ctx context.Context, signature string) error

DeleteAccessTokenSession deletes an access token session

func (*OAuth2Storage) DeleteOpenIDConnectSession

func (s *OAuth2Storage) DeleteOpenIDConnectSession(ctx context.Context, signature string) error

DeleteOpenIDConnectSession implements openid.OpenIDConnectRequestStorage interface

func (*OAuth2Storage) DeletePKCERequestSession

func (s *OAuth2Storage) DeletePKCERequestSession(ctx context.Context, signature string) error

DeletePKCERequestSession deletes a PKCE request session

func (*OAuth2Storage) DeleteRefreshTokenSession

func (s *OAuth2Storage) DeleteRefreshTokenSession(ctx context.Context, signature string) error

DeleteRefreshTokenSession deletes a refresh token session

func (*OAuth2Storage) DenyDeviceCodeSession

func (s *OAuth2Storage) DenyDeviceCodeSession(ctx context.Context, userCode string) error

DenyDeviceCodeSession denies a device code (user rejected the device)

func (*OAuth2Storage) EnsureGrantAuthorizedScopes added in v0.19.0

func (s *OAuth2Storage) EnsureGrantAuthorizedScopes(ctx context.Context, requestID string, scopes []string) error

EnsureGrantAuthorizedScopes records what a grant was authorized with, for grants that predate its being captured at consent.

Every grant in existence when that started being recorded has none, and without this the admin page reads the scopes in force as the whole authorization. Switching one off then shrinks the set it would restore from, so the scope cannot be put back -- the one-way door the toggle exists to remove, for exactly the grants an operator already had.

Called with the set in force BEFORE a change, which for a grant nobody has touched is what it was authorized with. A no-op once a value is present, so a later narrowing cannot overwrite the original.

The session is rewritten through a generic map rather than the Session type: decoding into Session and re-encoding would drop any field this build does not know about, and a token session carries the OIDC claims.

func (*OAuth2Storage) FindGrantBySignaturePrefix added in v0.17.0

func (s *OAuth2Storage) FindGrantBySignaturePrefix(ctx context.Context, kind, prefix string) (GrantRef, error)

FindGrantBySignaturePrefix resolves the fingerprint shown in the admin token listing back to the grant behind it.

kind selects the table, because the listing shows access and refresh tokens together and their signatures live in different ones.

func (*OAuth2Storage) GetAccessTokenSession

func (s *OAuth2Storage) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error)

GetAccessTokenSession retrieves an access token session

func (*OAuth2Storage) GetAuthorizeCodeSession

func (s *OAuth2Storage) GetAuthorizeCodeSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error)

GetAuthorizeCodeSession retrieves an authorization code session

func (*OAuth2Storage) GetClient

func (s *OAuth2Storage) GetClient(ctx context.Context, clientID string) (fosite.Client, error)

GetClient retrieves a client by ID

func (*OAuth2Storage) GetDB

func (s *OAuth2Storage) GetDB() *sql.DB

GetDB returns the underlying database connection. Kept on the struct because tests and the SessionStore wiring still reach for it.

func (*OAuth2Storage) GetDeviceCodeSession

func (s *OAuth2Storage) GetDeviceCodeSession(ctx context.Context, deviceCode string, session fosite.Session) (fosite.Requester, error)

GetDeviceCodeSession retrieves a device code session by device code

func (*OAuth2Storage) GetDeviceCodeSessionByUserCode

func (s *OAuth2Storage) GetDeviceCodeSessionByUserCode(ctx context.Context, userCode string) (string, fosite.Requester, error)

GetDeviceCodeSessionByUserCode retrieves a device code session by user code

func (*OAuth2Storage) GetOpenIDConnectSession

func (s *OAuth2Storage) GetOpenIDConnectSession(ctx context.Context, signature string, requester fosite.Requester) (fosite.Requester, error)

GetOpenIDConnectSession implements openid.OpenIDConnectRequestStorage interface

func (*OAuth2Storage) GetPKCERequestSession

func (s *OAuth2Storage) GetPKCERequestSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error)

GetPKCERequestSession retrieves a PKCE request session

func (*OAuth2Storage) GetRefreshTokenSession

func (s *OAuth2Storage) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error)

GetRefreshTokenSession retrieves a refresh token session

func (*OAuth2Storage) GrantAuthorizedScopes added in v0.18.0

func (s *OAuth2Storage) GrantAuthorizedScopes(ctx context.Context, requestID string) ([]string, error)

GrantAuthorizedScopes reads what a grant's authorization ENDED with: the set an operator may restore it to.

Read from the stored session rather than a column of its own, because that session is what fosite carries forward across every refresh -- a column would have to be re-derived on each new token row, and the set it has to preserve is the one from the ORIGINAL authorization, not from whatever the grant has been narrowed to since.

A grant issued before this was recorded has none. Its current scopes are then the only defensible bound: the alternative is inventing an authorization nobody made.

func (*OAuth2Storage) GrantScopes added in v0.18.0

func (s *OAuth2Storage) GrantScopes(ctx context.Context, requestID string) ([]string, error)

GrantScopes reads the scopes currently granted under one grant.

Read from the access token where there is one, falling back to the refresh token: the two carry the same granted set by construction, and a grant whose access token has expired still has a refresh token an operator may want to narrow.

func (*OAuth2Storage) InvalidateAuthorizeCodeSession

func (s *OAuth2Storage) InvalidateAuthorizeCodeSession(ctx context.Context, signature string) error

InvalidateAuthorizeCodeSession invalidates an authorization code

func (*OAuth2Storage) InvalidateDeviceCodeSession

func (s *OAuth2Storage) InvalidateDeviceCodeSession(ctx context.Context, deviceCode string) error

InvalidateDeviceCodeSession invalidates a device code after it's been used

func (*OAuth2Storage) LoadHMACSecret

func (s *OAuth2Storage) LoadHMACSecret(ctx context.Context) ([]byte, error)

LoadHMACSecret loads the HMAC secret. See LoadRSAKey for the encryption-vs-plaintext branching.

func (*OAuth2Storage) LoadRSAKey

func (s *OAuth2Storage) LoadRSAKey(ctx context.Context) (string, error)

LoadRSAKey loads the RSA private key. Falls back to plaintext when the row's DEK column is NULL — that's the pre-encryption format and the format used when no KEK is configured. When a DEK is present but no sealer is configured (KEK was removed without rotating data), returns an explicit error rather than handing back ciphertext or silently regenerating the key.

func (*OAuth2Storage) RevokeAccessToken

func (s *OAuth2Storage) RevokeAccessToken(ctx context.Context, requestID string) error

RevokeAccessToken revokes an access token

func (*OAuth2Storage) RevokeAllForSubject added in v0.13.0

func (s *OAuth2Storage) RevokeAllForSubject(ctx context.Context, subject string) (int64, error)

RevokeAllForSubject deactivates every access and refresh token belonging to one subject, across all clients.

This is the operator's answer to "this person is gone, cut them off now". The refresh-time oracles handle the steady state, but they only fire when the user's client next shows up, and only when an oracle can see the removal at all; an admin needs a way to act immediately and unconditionally.

It returns the number of token rows deactivated. Rows are marked inactive rather than deleted so the admin token listing can still show what was revoked.

func (*OAuth2Storage) RevokeGrant added in v0.17.0

func (s *OAuth2Storage) RevokeGrant(ctx context.Context, requestID string) (int64, error)

RevokeGrant deactivates every token issued under one grant -- the access token and the refresh token that came with it.

Revoking only the access token would be theatre: a client holding the refresh token mints a new one within minutes, which is exactly the case an operator reaches for this to stop. Rows are deactivated rather than deleted, matching RevokeAllForSubject, so the listing can still show what was revoked.

func (*OAuth2Storage) RevokeRefreshToken

func (s *OAuth2Storage) RevokeRefreshToken(ctx context.Context, requestID string) error

RevokeRefreshToken revokes a refresh token

func (*OAuth2Storage) RevokeRefreshTokenMaybeGracePeriod

func (s *OAuth2Storage) RevokeRefreshTokenMaybeGracePeriod(ctx context.Context, requestID string, _ string) error

RevokeRefreshTokenMaybeGracePeriod implements fosite.TokenRevocationStorage interface This handles refresh token revocation. The signature parameter allows for grace period implementation but for simplicity we immediately revoke the token by request ID

func (*OAuth2Storage) RotateRefreshToken

func (s *OAuth2Storage) RotateRefreshToken(ctx context.Context, requestID string, _ string) error

RotateRefreshToken revokes the refresh token and its associated access token for the request, mirroring fosite's reference rotation semantics (required by the RefreshTokenStorage interface as of v0.49). The refresh token signature is unused because revocation is keyed by request ID.

func (*OAuth2Storage) SaveHMACSecret

func (s *OAuth2Storage) SaveHMACSecret(ctx context.Context, secret []byte) error

SaveHMACSecret stores the HMAC secret. See SaveRSAKey for the encryption-vs-plaintext branching.

func (*OAuth2Storage) SaveRSAKey

func (s *OAuth2Storage) SaveRSAKey(ctx context.Context, privateKeyPEM string) error

SaveRSAKey stores the RSA private key. When a sealer is set the PEM bytes are encrypted under a fresh per-row DEK (itself wrapped by the DB-instance KEK); when no sealer is configured the PEM is written verbatim — same on-disk shape as the pre-KEK schema, kept for back-compat with deployments that haven't enabled encryption.

func (*OAuth2Storage) SetClientAssertionJWT

func (s *OAuth2Storage) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error

SetClientAssertionJWT implements fosite.SetClientAssertionJWT interface This stores the JTI (JWT ID) with expiration to prevent replay attacks

func (*OAuth2Storage) SetGrantScopes added in v0.18.0

func (s *OAuth2Storage) SetGrantScopes(ctx context.Context, requestID string, scopes []string) (int64, error)

SetGrantScopes rewrites the granted scopes of every token issued under one grant.

Applied to the whole grant for the same reason RevokeGrant is: narrowing only the access token would be undone at the next refresh, minutes later, by a client that still holds a refresh token carrying the old set. The operator reaching for this wants the narrowing to stick.

Only granted_scopes is rewritten. The `scopes` column records what was REQUESTED, which is a fact about a past request and not this server's to revise -- and reauthorizeRefreshGrant re-derives the allowed set from the granted one, so that is the column that decides what the token can do.

func (*OAuth2Storage) SetSealer

func (s *OAuth2Storage) SetSealer(sealer *seal.Sealer)

SetSealer enables envelope encryption for the long-lived secret columns (RSA private key, HMAC secret). Calling with nil restores plaintext mode. Callers should set this once at startup, before SaveRSAKey / SaveHMACSecret have a chance to fire — the startup-backfill path in NewHandler does exactly that.

func (*OAuth2Storage) UpdateDeviceCodePolling

func (s *OAuth2Storage) UpdateDeviceCodePolling(ctx context.Context, deviceCode string) error

UpdateDeviceCodePolling updates the last polled timestamp for rate limiting

type PeekResponse

type PeekResponse struct {
	Stdout *PeekedStreamResponse `json:"stdout,omitempty"`
	Stderr *PeekedStreamResponse `json:"stderr,omitempty"`
}

PeekResponse mirrors htcondor.PeekResult on the wire. Fields that weren't requested (or that the starter elected not to return) are omitted entirely so the SPA can detect "stream wasn't transferred" without inferring from a zero-length string.

type PeekedStreamResponse

type PeekedStreamResponse struct {
	Text   string `json:"text"`
	Offset int64  `json:"offset"`
}

PeekedStreamResponse is the JSON shape returned for one of the requested streams. `bytes` is the raw text the starter sent (the caller is responsible for handling NUL/binary content if it shows up — stdout/stderr are nearly always UTF-8). `offset` is the absolute file offset *after* this read; pass it back as `stdout_offset` / `stderr_offset` on the next call to follow.

type PingResponse

type PingResponse struct {
	Daemon         string `json:"daemon"`               // "collector" or "schedd"
	AuthMethod     string `json:"auth_method"`          // Authentication method used
	User           string `json:"user"`                 // Authenticated username
	SessionID      string `json:"session_id"`           // Session identifier
	ValidCommands  string `json:"valid_commands"`       // Commands authorized
	Encryption     bool   `json:"encryption"`           // Whether encryption is enabled
	Authentication bool   `json:"authentication"`       // Whether authentication is enabled
	Authorized     bool   `json:"authorized,omitempty"` // Whether authorized for requested permission (if permission checked)
	Permission     string `json:"permission,omitempty"` // Permission level checked (if any)
}

PingResponse represents a ping response for a daemon

type ReauthDecision added in v0.13.0

type ReauthDecision struct {
	// Status is the verdict on the user as a whole.
	Status UserStatus
	// Reason is a short operator-facing explanation, surfaced in logs and
	// (for revocations) in the OAuth2 error description. HTCondor's
	// per-user records carry a DisableReason string that lands here.
	Reason string
	// DeniedScopes are scopes the oracle says this user may no longer
	// hold, even when Status is Active. They are removed from the
	// refreshed grant rather than failing it, so a user who loses write
	// access keeps working read-only instead of being logged out.
	DeniedScopes []string
}

ReauthDecision is what a RevocationOracle reports about one user.

type RecentJob added in v0.14.1

type RecentJob struct {
	ClusterID int64  `json:"cluster_id"`
	ProcID    int64  `json:"proc_id"`
	Owner     string `json:"owner,omitempty"`
	// At is when the event this list is about happened, unix seconds.
	At int64 `json:"at"`
	// Detail is the one fact worth showing beside it: a hold reason, the
	// executable, the host it started on.
	Detail string `json:"detail,omitempty"`
	// Archived says this row came from the history archive rather than
	// the live queue, which decides where a click on it should go. The
	// queue destroys a finished job within seconds, so most completions
	// shown here no longer have a job page -- linking them all to one
	// sent people to "not found".
	Archived bool `json:"archived,omitempty"`
}

RecentJob is one entry in a recent-activity list.

type RevocationOracle added in v0.13.0

type RevocationOracle interface {
	// Name identifies the oracle in log lines.
	Name() string
	// Check reports on username, which is the grant's subject. scopes is
	// the set currently granted, so an oracle can skip work for scopes
	// nobody holds.
	Check(ctx context.Context, username string, scopes []string) (ReauthDecision, error)
}

RevocationOracle answers "is this user still entitled to what they were granted?" at refresh time.

Implementations must fail OPEN: a backend that is unreachable, slow, or simply has no record of the user returns UserStatusUnknown, never UserStatusRevoked. A refresh endpoint that hard-denies whenever a dependency hiccups is an outage amplifier, and the absolute grant lifetime cap is what bounds exposure when every oracle is silent.

type ScheddACLOracle added in v0.13.0

type ScheddACLOracle struct {
	Schedd    func() *htcondor.Schedd
	UIDDomain string
	Logger    *logging.Logger
	// MintToken produces an HTCondor IDTOKEN asserting username, used as
	// the probe credential. The scopes argument is passed through to the
	// handler's minter; Check passes nil so the probe token carries no
	// limit_authz narrowing (see Check for why).
	MintToken func(username string, scopes []string) (string, error)
}

ScheddACLOracle strips scopes whose HTCondor authorization level the schedd would refuse for this user, by running a DC_SEC_QUERY probe — the same question condor_ping asks.

It is the weaker of the two oracles and is deliberately scoped to narrowing rather than revoking. Two reasons:

  • It cannot see identity at all. The MCP server mints the IDTOKEN it probes with, so the schedd is being asked "do your ACLs admit this name", not "does this person still exist". A deleted user whose name still matches ALLOW_WRITE passes.
  • ALLOW_WRITE is `*@uid_domain` in many pools, in which case the probe tells you nothing about any individual.

What it does catch is a pool that genuinely enumerates users in its ACLs, where removing someone from ALLOW_WRITE should stop their write access without waiting for the grant's lifetime cap.

func (*ScheddACLOracle) Check added in v0.13.0

func (o *ScheddACLOracle) Check(ctx context.Context, username string, scopes []string) (ReauthDecision, error)

Check implements RevocationOracle.

It probes only the levels the user actually holds: a grant with no write scope never asks about WRITE. The verdict is always Active — this oracle answers a question about permissions, not about the person — with denied scopes listed for whatever the schedd refuses.

func (*ScheddACLOracle) Name added in v0.13.0

func (o *ScheddACLOracle) Name() string

Name implements RevocationOracle.

type Server

type Server struct {
	*Handler // Embedded handler for business logic
	// contains filtered or unexported fields
}

Server represents the HTTP API server

func NewServer

func NewServer(cfg Config) (*Server, error)

NewServer creates a new HTTP API server

func (*Server) GetAddr

func (s *Server) GetAddr() string

GetAddr returns the actual listening address of the server. Returns empty string if the server hasn't started yet.

func (*Server) ServeAdditionalListener added in v0.14.1

func (s *Server) ServeAdditionalListener(ln net.Listener, certFile, keyFile string) error

ServeAdditionalListener serves the already-running server on a second listener.

Under condor_master the daemon is handed a socket -- a shared-port endpoint or a pre-created command socket -- and that is what carries CEDAR commands. An operator who also wants the web UI on a port of their choosing, 443 being the one people ask for, needs both at once: the master's socket for the pool, and a directly dialable port for browsers. The master cannot be told to hand down 443, so this process has to bind it itself.

The handler is started by ServeListenerWithCert and must not be started again -- doing so would register every route a second time and start a second copy of each background goroutine. This only feeds another listener into the same server, so call it after the primary one is serving.

func (*Server) ServeListener

func (s *Server) ServeListener(ln net.Listener, scheme string) error

ServeListener runs the API server on a caller-supplied net.Listener. scheme controls which protocol the request URLs are advertised under ("http" or "https") — use "https" if the caller has configured httpServer.TLSConfig, "http" otherwise.

This is the entry point used when condor_master spawns us as a managed daemon and we accept forwarded connections from condor_shared_port via a sharedport.Listener instead of binding our own TCP port. The handler bootstrap (issuer URL, OAuth2 setup) is the same as Start/StartTLS; the only difference is the kind of listener we hand to http.Server.Serve.

func (*Server) ServeListenerWithCert

func (s *Server) ServeListenerWithCert(ln net.Listener, certFile, keyFile string) error

ServeListenerWithCert is the listener-injecting analog of Start/StartTLS used by the daemon framework, which supplies the (shared-port or TCP) listener: it runs the handler then serves on ln, terminating TLS from certFile/keyFile when both are set (https) or speaking plain HTTP otherwise. It returns http.ErrServerClosed after Shutdown, like the standard library.

func (*Server) ServeMCPListener added in v0.14.1

func (s *Server) ServeMCPListener(ln net.Listener, certFile, keyFile string) error

ServeMCPListener serves only the MCP surface on its own listener.

A second http.Server rather than another listener on the first: the two ports deliberately expose different things, and that difference is the whole point of asking for a separate port. The handler is the same one, fronted by a filter.

The handler is started by ServeListenerWithCert and must not be started again, so call this after the primary listener is serving. Shutdown closes both.

func (*Server) Shutdown

func (s *Server) Shutdown(ctx context.Context) error

Shutdown gracefully shuts down the HTTP server

func (*Server) Start

func (s *Server) Start() error

Start starts the HTTP server

func (*Server) StartTLS

func (s *Server) StartTLS(certFile, keyFile string) error

StartTLS starts the HTTPS server with TLS

type Session added in v0.13.0

type Session struct {
	*openid.DefaultSession

	// Groups is the group list asserted by the upstream IDP (or the
	// browser session) at the time consent was granted.
	Groups []string `json:"groups,omitempty"`

	// AuthTime is when the user authenticated and consented, in UTC.
	// It is deliberately NOT refreshed when the grant is refreshed.
	AuthTime time.Time `json:"authTime,omitempty"`

	// AuthorizedScopes is what this authorization ENDED with: the set the
	// policy allowed and the user did not untick, recorded once at
	// consent and carried unchanged across every refresh.
	//
	// It is the bound on what an operator may re-enable from the admin
	// page. GrantedScope is the set in force now, which an operator may
	// have narrowed; this is the set that narrowing started from, so
	// putting one back is restoring something this user already agreed
	// to rather than granting something new. A scope the user unticked at
	// consent never appears here, so no operator can undo that choice.
	AuthorizedScopes []string `json:"authorizedScopes,omitempty"`

	// Actor names the client that obtained this token by RFC 8693 token
	// exchange on the subject's behalf (delegation): the token acts AS Subject
	// but was minted FOR this actor. Empty for tokens obtained directly. It is
	// the `act.sub` an exchanged token records, and is surfaced for audit and
	// on the HTCondor IDTOKEN minted downstream.
	Actor string `json:"actor,omitempty"`
}

Session is the fosite session persisted for every grant this server issues, for both the MCP OAuth2 provider and the built-in IDP.

It exists to carry the *inputs* of the authorization decision alongside its output, so that a refresh grant can recompute the decision instead of replaying it. fosite's refresh pipeline rebuilds a request by cloning the stored session and re-granting whatever scopes the original grant carried (see handler/oauth2/flow_refresh.go), so anything the refresh path needs has to survive that round trip. Two fields do that work:

  • Groups: the IDP-asserted group memberships that produced the granted scopes at consent time. They are read once from the userinfo endpoint and otherwise dropped on the floor, so without persisting them here the refresh path cannot re-run getScopesForGroups at all. Note this is a snapshot, not a live reading — see reauthorizeRefreshGrant for what that does and does not catch.
  • AuthTime: when the human actually authenticated and consented. Every refresh resets the refresh token's own expiry, so AuthTime is the only fixed point from which an absolute cap on the grant can be measured.

Both are advisory inputs to reauthorizeRefreshGrant; nothing else reads them, and a session that predates this type (deserialized from a row written by an older build) simply has them zero-valued. See reauthorizeRefreshGrant for how that case is handled.

func DefaultIDPSession

func DefaultIDPSession(username string) *Session

DefaultIDPSession creates a default OpenID Connect session for the IDP. See DefaultOpenIDConnectSession for why this returns *Session.

func DefaultOpenIDConnectSession

func DefaultOpenIDConnectSession(username string) *Session

DefaultOpenIDConnectSession creates a default OpenID Connect session.

The concrete type is *Session, not *openid.DefaultSession: grants issued by this server must carry the group list and auth time that reauthorizeRefreshGrant re-checks when the grant is later refreshed.

func (*Session) Clone added in v0.13.0

func (s *Session) Clone() fosite.Session

Clone deep-copies the session, including the fields declared above.

This override is load-bearing; do not delete it. openid.DefaultSession has its own Clone that reflection-copies its receiver, and if that method were left to promote through the embedded pointer it would return a bare *openid.DefaultSession and silently drop Groups and AuthTime. fosite's refresh handler rebuilds every refreshed request via originalRequest.GetSession().Clone(), so a promoted Clone would erase exactly the data the refresh path exists to consult — and erase it quietly, because the result still satisfies fosite.Session and a missing group list is indistinguishable from "this user has no groups".

TestSessionClonedeepPreservesFields guards this.

func (*Session) WithAuthorizedScopes added in v0.18.0

func (s *Session) WithAuthorizedScopes(scopes []string) *Session

WithAuthorizedScopes records what this authorization ended with, which is the bound on what an operator may later re-enable. See AuthorizedScopes.

Called at every site that grants scopes, so that a grant made through a path which forgets is visible as a grant nothing can be restored to, rather than one an operator can quietly widen.

func (*Session) WithGroups added in v0.13.0

func (s *Session) WithGroups(groups []string) *Session

WithGroups records the group memberships that authorized this grant and returns the session, for chaining at the consent call sites.

type SessionData

type SessionData struct {
	Username  string    // Authenticated username
	Groups    []string  // User groups from IDP (for scope filtering)
	CreatedAt time.Time // When the session was created
	ExpiresAt time.Time // When the session expires
}

SessionData represents the data stored in a session.

Note: this struct used to carry a Token field that was reserved for per-session HTCondor token storage. The column was never written to and the field is gone — the schema migration in 0002_envelope_encryption.sql drops `http_sessions.token` to remove the unused secret-shaped column. Per-user tokens, when needed, flow through the OAuth2 / IDP storage tables instead.

type SessionStore

type SessionStore struct {
	// contains filtered or unexported fields
}

SessionStore manages HTTP sessions with SQLite persistence

func NewSessionStore

func NewSessionStore(db *sql.DB, ttl time.Duration) (*SessionStore, error)

NewSessionStore creates a new session store with database persistence The db parameter should be the same database connection used by OAuth2Storage

func (*SessionStore) Cleanup

func (s *SessionStore) Cleanup()

Cleanup removes expired sessions

func (*SessionStore) Create

func (s *SessionStore) Create(username string, groups ...[]string) (string, *SessionData, error)

Create creates a new session for the given username and groups

func (*SessionStore) Delete

func (s *SessionStore) Delete(sessionID string)

Delete removes a session

func (*SessionStore) Get

func (s *SessionStore) Get(sessionID string) *SessionData

Get retrieves a session by ID Returns nil if session doesn't exist or has expired

func (*SessionStore) Size

func (s *SessionStore) Size() int

Size returns the number of active sessions

type ShareInputResponse added in v0.17.0

type ShareInputResponse struct {
	ClusterID      int                `json:"cluster_id"`
	Owner          string             `json:"owner"`
	ExpiresAt      time.Time          `json:"expires_at"`
	TTLSeconds     int                `json:"ttl_seconds"`
	Count          int                `json:"count"`
	ProcsRemaining int                `json:"procs_remaining,omitempty"`
	Uploads        []ShareInputUpload `json:"uploads"`
	Note           string             `json:"note,omitempty"`
}

ShareInputResponse is what a mint call returns. Always a list, even for a single proc: HTCondor spools per proc, so "the upload URL for this submission" is inherently plural, and one shape is easier to consume than a response that changes with the request.

Owner and the expiry are shared by every URL in one response; only the URL and its allow-set vary per proc.

type ShareInputUpload added in v0.17.0

type ShareInputUpload struct {
	JobID         string   `json:"job_id"`
	URL           string   `json:"url"`
	ExpectedFiles []string `json:"expected_files"`
}

ShareInputUpload is one job's upload URL.

ExpectedFiles is the load-bearing field: the schedd accepts only the names in a job's allow-set and drops the rest of the tar in silence, and whoever redeems this URL is often not the person who wrote the submit file. Without the list they have no way to learn which names are expected until the job fails at execute time on a missing file. It is per-proc because the allow-set is: procs of one cluster can list different inputs.

type ShareOutputRequest

type ShareOutputRequest struct {
	TTLSeconds int `json:"ttl_seconds,omitempty"`
}

ShareOutputRequest is the body for POST /api/v1/jobs/{id}/output/share.

type ShareOutputResponse

type ShareOutputResponse struct {
	URL        string    `json:"url"`
	ExpiresAt  time.Time `json:"expires_at"`
	TTLSeconds int       `json:"ttl_seconds"`
	Owner      string    `json:"owner"`
}

ShareOutputResponse is what the SPA gets back. Owner is echoed for UX so the share preview can label the URL with "downloads as <owner>".

type ShareWatchRequest added in v0.17.0

type ShareWatchRequest struct {
	TTLSeconds int `json:"ttl_seconds,omitempty"`
}

ShareWatchRequest is the body for POST /api/v1/watches/{id}/share.

type ShareWatchResponse added in v0.17.0

type ShareWatchResponse struct {
	URL        string    `json:"url"`
	WatchID    string    `json:"watch_id"`
	Owner      string    `json:"owner"`
	ExpiresAt  time.Time `json:"expires_at"`
	TTLSeconds int       `json:"ttl_seconds"`
	// MaxWaitSeconds is what the redeem endpoint will block for, so a
	// poller can size its own client timeout from the answer rather than
	// from a number hard-coded on its side.
	MaxWaitSeconds     int `json:"max_wait_seconds"`
	DefaultWaitSeconds int `json:"default_wait_seconds"`
}

ShareWatchResponse is what a mint call returns.

type SharedInputResult added in v0.17.0

type SharedInputResult struct {
	Message       string   `json:"message"`
	JobID         string   `json:"job_id"`
	ExpectedFiles []string `json:"expected_files"`
	IgnoredFiles  []string `json:"ignored_files,omitempty"`
	Warning       string   `json:"warning,omitempty"`
}

SharedInputResult is what a redeemed upload returns. Unexpected names the schedd dropped are reported rather than swallowed -- see ShareInputResponse.ExpectedFiles.

type SuperuserModeRequest added in v0.13.0

type SuperuserModeRequest struct {
	Enabled bool `json:"enabled"`
}

SuperuserModeRequest toggles superuser mode for the calling session.

type SuperuserModeResponse added in v0.13.0

type SuperuserModeResponse struct {
	Active    bool       `json:"active"`
	ExpiresAt *time.Time `json:"expires_at,omitempty"`
	// Identity is what the server will authenticate to the schedd as while
	// the mode is on, and ActorIsQueueSuperUser whether that is the caller
	// themselves. Surfaced because the two differ in how well the action
	// can be attributed: as themselves, the schedd records the human; via
	// the shared account, only this server and the job's reason string do.
	Identity              string `json:"identity,omitempty"`
	ActorIsQueueSuperUser bool   `json:"actor_is_queue_superuser,omitempty"`
	// Note explains a fallback when one happened, including how to fix it.
	// Empty when the operator is acting as themselves.
	Note string `json:"note,omitempty"`
}

SuperuserModeResponse reports the resulting state.

type TokenCache

type TokenCache struct {
	// contains filtered or unexported fields
}

TokenCache manages validated tokens and their associated session caches

func NewTokenCache

func NewTokenCache() *TokenCache

NewTokenCache creates a new token cache

func (*TokenCache) Add

func (tc *TokenCache) Add(token string) (*TokenCacheEntry, error)

Add adds a validated token to the cache with a session cache If the token is already in the cache, returns the existing entry Automatically schedules cleanup when the token expires

func (*TokenCache) AddValidated

func (tc *TokenCache) AddValidated(token, username string, expiration time.Time) (*TokenCacheEntry, error)

AddValidated adds a pre-validated token (e.g. opaque token) to the cache

func (*TokenCache) Get

func (tc *TokenCache) Get(token string) (*TokenCacheEntry, bool)

Get retrieves a token cache entry if it exists and is not expired

func (*TokenCache) MarkValidated

func (tc *TokenCache) MarkValidated(token, authoritativeUsername string)

MarkValidated promotes a cached token to "validated" status, meaning a schedd op has authenticated successfully with it. Callers may optionally pass an authoritativeUsername observed from the schedd handshake — if non-empty and different from the JWT-claimed username, the entry is updated to the schedd-authoritative value (this protects against any case where the unverified sub claim disagreed with the schedd's interpretation).

Idempotent: safe to call repeatedly per request.

func (*TokenCache) Remove

func (tc *TokenCache) Remove(token string)

Remove removes a token from the cache and cancels its cleanup timer

func (*TokenCache) Size

func (tc *TokenCache) Size() int

Size returns the number of cached tokens

func (*TokenCache) ValidatedUsername

func (tc *TokenCache) ValidatedUsername(token string) string

ValidatedUsername returns the username for a token only if it has been marked validated via a successful schedd handshake. Use this in code paths that must rely on authoritative identity (job-owner filtering, share-URL minting, audit logs). For loose use cases (rate-limit bucket key) the Get-and-read-Username pattern is fine.

Returns "" if the token is unknown, expired, or not yet validated.

type TokenCacheEntry

type TokenCacheEntry struct {
	Token        string
	Username     string // sub from the JWT — unverified until Validated == true
	Validated    bool   // true once a schedd op authenticated successfully with this token
	Expiration   time.Time
	SessionCache *security.SessionCache
	// contains filtered or unexported fields
}

TokenCacheEntry represents a cached token with its expiration and associated session cache.

Identity-trust note: Username is parsed from the JWT WITHOUT verifying the signature (we have no local way to verify — the only authoritative validator is the schedd's CEDAR handshake, which happens later when we make a schedd call). Until that handshake succeeds, the Username reflects whatever the client put in the token's `sub` claim and MUST NOT be used as authoritative identity (e.g. for filtering jobs to "owned by me", recording the Owner when minting a share URL, or any other authorization decision).

Validated reports whether at least one schedd op has succeeded with this token. Code paths that need authoritative identity should gate on Validated; code paths that only need a stable bucket key (rate-limit per-token / per-username) can use Username directly.

type UpstreamRefreshMode added in v0.18.0

type UpstreamRefreshMode string

UpstreamRefreshMode decides whether that credential is kept and used.

const (
	// UpstreamRefreshAuto keeps and uses the credential when the provider
	// hands one over, and does nothing when it does not.
	//
	// The default, because whether a provider releases offline_access is
	// the provider's decision and not every one will. What this server
	// ASKS for is already the operator's to set (HTTP_API_OAUTH2_SCOPES);
	// auto does not add to that request, so enabling this cannot break a
	// login against a provider that rejects a scope it does not know.
	UpstreamRefreshAuto UpstreamRefreshMode = "auto"

	// UpstreamRefreshOn is auto plus a complaint: a provider that returns
	// no refresh token is a misconfiguration the operator asked to hear
	// about, rather than a silent fallback to never checking.
	UpstreamRefreshOn UpstreamRefreshMode = "on"

	// UpstreamRefreshOff never stores the credential. For a deployment
	// that would rather not hold one at all, which is a defensible
	// position: it is a long-lived key to somebody else's identity
	// provider, and the account-database path covers the same ground
	// where an account database exists.
	UpstreamRefreshOff UpstreamRefreshMode = "off"
)

func ParseUpstreamRefreshMode added in v0.18.0

func ParseUpstreamRefreshMode(raw string) (UpstreamRefreshMode, error)

ParseUpstreamRefreshMode reads HTTP_API_UPSTREAM_REFRESH.

type UserInfo

type UserInfo struct {
	Subject string                 `json:"sub"`
	Email   string                 `json:"email"`
	Name    string                 `json:"name"`
	Groups  interface{}            `json:"groups"` // Can be []string or string
	Claims  map[string]interface{} // Additional claims
}

UserInfo represents user information from the IDP

type UserRecordLookup added in v0.13.0

type UserRecordLookup interface {
	GetUserRecord(ctx context.Context, user string) (*htcondor.UserRecord, error)
}

UserRecordLookup is the slice of the schedd client that UserRecordOracle needs. It exists so the oracle's semantics — above all what a missing record means — can be tested without a live schedd, since that distinction is the difference between "new user" and "locked out".

type UserRecordOracle added in v0.13.0

type UserRecordOracle struct {
	// Lookup supplies the current schedd client. It is a function rather
	// than a value because the handler re-creates its Schedd when the
	// daemon's address changes.
	Lookup func() UserRecordLookup
	// UIDDomain qualifies bare usernames before the lookup; schedd records
	// are keyed on the fully-qualified "user@domain" form.
	UIDDomain string
	Logger    *logging.Logger
	// Strict makes "no record at all" a revocation instead of no opinion.
	//
	// Off by default, because the schedd creates records lazily on first
	// submit: in an ordinary pool "no record" means "has never submitted
	// here", and revoking on it would cut off every new user.
	//
	// It becomes correct — and much stronger — in a pool that provisions a
	// record for every user up front, since `condor_qusers -add <user>`
	// (ENABLE_USERREC with the create option) creates one without the user
	// submitting anything. There, absence really does mean "not a user of
	// this AP", and `condor_qusers -delete` becomes a revocation an admin
	// can perform. Do not enable it otherwise.
	Strict bool
}

UserRecordOracle reports a user revoked when the schedd's per-user record says so — that is, when an admin has run `condor_qusers -disable <user> -reason "..."`.

This is the stronger of the two schedd-backed oracles, because it reads an explicit administrative act rather than inferring one from an ACL. The record's DisableReason is carried through to the OAuth2 error, so the operator's note reaches whoever is looking at the failure.

Absence of a record is deliberately NOT a denial. The schedd creates a record the first time a user submits, so "no record" routinely means "this user has never submitted here" — denying on it would lock out every new user and everyone on a freshly built pool. Set Strict only where every user is provisioned up front with `condor_qusers -add`.

func (*UserRecordOracle) Check added in v0.13.0

func (o *UserRecordOracle) Check(ctx context.Context, username string, _ []string) (ReauthDecision, error)

Check implements RevocationOracle.

func (*UserRecordOracle) Name added in v0.13.0

func (o *UserRecordOracle) Name() string

Name implements RevocationOracle.

type UserStatus added in v0.13.0

type UserStatus int

UserStatus is an oracle's verdict on whether a user is still entitled to hold a grant issued to them earlier.

const (
	// UserStatusUnknown means the oracle has no opinion — it could not
	// reach its backing store, or the backing store has no record of this
	// user. It is NOT a denial: absence of a record is routinely
	// indistinguishable from "this user has simply never been seen here",
	// and denying on it would lock out every user of a fresh pool.
	UserStatusUnknown UserStatus = iota
	// UserStatusActive means the oracle affirmatively vouches for the user.
	UserStatusActive
	// UserStatusRevoked means the oracle affirmatively says this user may
	// no longer hold a grant. Only this verdict revokes.
	UserStatusRevoked
)

func (UserStatus) String added in v0.13.0

func (s UserStatus) String() string

type VersionResponse

type VersionResponse struct {
	Version string `json:"version"`
	Commit  string `json:"commit"`
	// StartTime is when this server process came up, RFC3339 in UTC.
	StartTime string `json:"start_time"`
	// UptimeSeconds is StartTime expressed as an elapsed duration, so a
	// caller does not have to trust its own clock to agree with ours.
	UptimeSeconds int64 `json:"uptime_seconds"`
}

VersionResponse represents a build-info response.

type WhoAmIResponse

type WhoAmIResponse struct {
	Authenticated bool   `json:"authenticated"`
	User          string `json:"user,omitempty"` // Omit if not authenticated
}

WhoAmIResponse represents a whoami response

Source Files

Directories

Path Synopsis
Package apikey implements the wire format and crypto for HTTP API authentication tokens this server issues for non-interactive callers (Prometheus, scripts, CI).
Package apikey implements the wire format and crypto for HTTP API authentication tokens this server issues for non-interactive callers (Prometheus, scripts, CI).
Package appdb owns the single SQLite database the HTTP API server uses for OAuth2/MCP storage, the embedded IDP, browser sessions, and user-saved batch-submission templates.
Package appdb owns the single SQLite database the HTTP API server uses for OAuth2/MCP storage, the embedded IDP, browser sessions, and user-saved batch-submission templates.
seal
Package seal provides envelope encryption for sensitive columns in the unified application database.
Package seal provides envelope encryption for sensitive columns in the unified application database.
Package chat provides the LLM-backed chat endpoint that powers the "Ask about your jobs" surface in the SPA.
Package chat provides the LLM-backed chat endpoint that powers the "Ask about your jobs" surface in the SPA.
Package jupyterhelperbin without the embed_jupyter_helper tag is a stub that reports "not embedded" — this is the default for `go build ./...` and for dev workflows that don't want a long Makefile dance every time the api binary is rebuilt.
Package jupyterhelperbin without the embed_jupyter_helper tag is a stub that reports "not embedded" — this is the default for `go build ./...` and for dev workflows that don't want a long Makefile dance every time the api binary is rebuilt.
Package webui provides the embedded Next.js static export.
Package webui provides the embedded Next.js static export.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL