Compare commits
10
Commits
68bbb53976
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
977603ffab | ||
|
|
d9881bde01 | ||
|
|
c6a830cb92 | ||
|
|
779db4cd94 | ||
|
|
31b726b439 | ||
|
|
921b760f80 | ||
|
|
f7cf22f970 | ||
|
|
d97b1661c1 | ||
|
|
34f13c65e1 | ||
|
|
ea7470231f |
No files matched your search
+39
-11
@@ -4,6 +4,23 @@ KEYWARDEN_DEBUG=True
|
|||||||
KEYWARDEN_ALLOWED_HOSTS=*
|
KEYWARDEN_ALLOWED_HOSTS=*
|
||||||
KEYWARDEN_TRUSTED_ORIGINS=https://reverse.proxy.domain.xyz,https://127.0.0.1
|
KEYWARDEN_TRUSTED_ORIGINS=https://reverse.proxy.domain.xyz,https://127.0.0.1
|
||||||
KEYWARDEN_DOMAIN=https://example.domain.xyz
|
KEYWARDEN_DOMAIN=https://example.domain.xyz
|
||||||
|
KEYWARDEN_IDP_ACCOUNT_PORTAL_URL=
|
||||||
|
KEYWARDEN_SHELL_REMOTE_COMMAND=/bin/bash
|
||||||
|
# Set to 1 to include SSH probe/certificate diagnostics in shell failures.
|
||||||
|
KEYWARDEN_SHELL_DEBUG=0
|
||||||
|
KEYWARDEN_SHELL_PRESERVE_TMP=0
|
||||||
|
KEYWARDEN_SHELL_DIAG_TIMEOUT_SECONDS=10
|
||||||
|
# Allowed values: accept-new (default), yes.
|
||||||
|
KEYWARDEN_SHELL_STRICT_HOST_KEY_CHECKING=accept-new
|
||||||
|
# Directory for CA private key files (stored as file:// refs in DB fields).
|
||||||
|
KEYWARDEN_CA_KEY_DIR=/var/lib/keywarden/ca-keys
|
||||||
|
# Optional legacy global fallback token for agents.
|
||||||
|
# Preferred model is per-agent token issued during /agent/enroll.
|
||||||
|
# KEYWARDEN_AGENT_API_TOKEN=
|
||||||
|
# Runtime compatibility switch:
|
||||||
|
# true = allow bearer fallback when mTLS headers are absent (default)
|
||||||
|
# false = strict mTLS-only runtime auth
|
||||||
|
# KEYWARDEN_AGENT_RUNTIME_ALLOW_TOKEN_FALLBACK=true
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
KEYWARDEN_POSTGRES_DB=keywarden
|
KEYWARDEN_POSTGRES_DB=keywarden
|
||||||
@@ -19,12 +36,28 @@ KEYWARDEN_ADMIN_EMAIL=admin@example.com
|
|||||||
KEYWARDEN_ADMIN_PASSWORD=password
|
KEYWARDEN_ADMIN_PASSWORD=password
|
||||||
|
|
||||||
# Auth mode: native | oidc | hybrid
|
# Auth mode: native | oidc | hybrid
|
||||||
# native: Uses JWT authentication based on Username / Password
|
# native: Uses Keywarden username/password login only. OIDC/social options are hidden and disabled.
|
||||||
# oidc: Disables in-build auth, exclusively uses OIDC configuration
|
# oidc: Disables built-in login and uses OIDC only. Users can be auto-onboarded from IdP claims.
|
||||||
# hybrid: Allows native auth, prefers oidc
|
# hybrid: Supports OIDC + Keywarden native login; login screen prefers OIDC/social options first.
|
||||||
|
|
||||||
KEYWARDEN_AUTH_MODE=native
|
KEYWARDEN_AUTH_MODE=native
|
||||||
|
|
||||||
|
# Email delivery (required for password reset + native email verification)
|
||||||
|
# KEYWARDEN_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||||
|
# KEYWARDEN_EMAIL_HOST=smtp.example.com
|
||||||
|
# KEYWARDEN_EMAIL_PORT=587
|
||||||
|
# KEYWARDEN_EMAIL_HOST_USER=
|
||||||
|
# KEYWARDEN_EMAIL_HOST_PASSWORD=
|
||||||
|
# KEYWARDEN_EMAIL_USE_TLS=true
|
||||||
|
# KEYWARDEN_EMAIL_USE_SSL=false
|
||||||
|
# KEYWARDEN_DEFAULT_FROM_EMAIL=keywarden@example.com
|
||||||
|
# KEYWARDEN_SERVER_EMAIL=keywarden@example.com
|
||||||
|
|
||||||
|
# Native email verification
|
||||||
|
# KEYWARDEN_EMAIL_VERIFICATION_ENABLED=true
|
||||||
|
# KEYWARDEN_EMAIL_VERIFICATION_TOKEN_MAX_AGE_SECONDS=86400
|
||||||
|
# KEYWARDEN_EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=300
|
||||||
|
|
||||||
|
|
||||||
# OIDC (optional) Currently supports Authentik officially and tested. Other solutions such as Keycloak may work.
|
# OIDC (optional) Currently supports Authentik officially and tested. Other solutions such as Keycloak may work.
|
||||||
# KEYWARDEN_OIDC_CLIENT_ID=
|
# KEYWARDEN_OIDC_CLIENT_ID=
|
||||||
@@ -41,24 +74,19 @@ KEYWARDEN_AUTH_MODE=native
|
|||||||
# KEYWARDEN_OIDC_EMAIL_CLAIM=email
|
# KEYWARDEN_OIDC_EMAIL_CLAIM=email
|
||||||
# KEYWARDEN_OIDC_EMAIL_VERIFIED_CLAIM=email_verified
|
# KEYWARDEN_OIDC_EMAIL_VERIFIED_CLAIM=email_verified
|
||||||
# KEYWARDEN_OIDC_USERNAME_CLAIM=preferred_username
|
# KEYWARDEN_OIDC_USERNAME_CLAIM=preferred_username
|
||||||
|
# KEYWARDEN_OIDC_ALLOW_AUTO_ONBOARDING=true
|
||||||
# KEYWARDEN_OIDC_GROUPS_CLAIM=groups
|
# KEYWARDEN_OIDC_GROUPS_CLAIM=groups
|
||||||
# KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS=false
|
# KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS=true # defaults to true when KEYWARDEN_AUTH_MODE=oidc
|
||||||
# KEYWARDEN_OIDC_ADMIN_GROUPS=authentik Admins
|
# KEYWARDEN_OIDC_ADMIN_GROUPS=authentik Admins
|
||||||
# KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS=false
|
# KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS=false
|
||||||
|
|
||||||
# Social login (optional)
|
# Social login (optional)
|
||||||
# KEYWARDEN_SITE_ID=1
|
# KEYWARDEN_SITE_ID=1
|
||||||
# KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL=true
|
# KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL=true
|
||||||
# KEYWARDEN_SOCIAL_GOOGLE_ENABLED=false
|
|
||||||
# KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID=
|
|
||||||
# KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET=
|
|
||||||
# KEYWARDEN_SOCIAL_GITHUB_ENABLED=false
|
# KEYWARDEN_SOCIAL_GITHUB_ENABLED=false
|
||||||
# KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID=
|
# KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID=
|
||||||
# KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET=
|
# KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET=
|
||||||
# KEYWARDEN_SOCIAL_APPLE_ENABLED=false
|
|
||||||
# KEYWARDEN_SOCIAL_APPLE_CLIENT_ID=
|
|
||||||
# KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# Authentik Secrets
|
# Authentik Secrets
|
||||||
AUTHENTIK_PG_PASS=
|
AUTHENTIK_PG_PASS=
|
||||||
AUTHENTIK_SECRET_KEY=
|
AUTHENTIK_SECRET_KEY=
|
||||||
+5
-1
@@ -223,4 +223,8 @@ nginx/certs/*.pem
|
|||||||
|
|
||||||
*.pdf
|
*.pdf
|
||||||
|
|
||||||
theme/__pycache__/*
|
theme/__pycache__/*
|
||||||
|
|
||||||
|
evidence/runs/
|
||||||
|
app/.*
|
||||||
|
app/scripts/node_modules
|
||||||
+1
-1
@@ -36,7 +36,7 @@ Endpoints:
|
|||||||
- `GET /api/v1/keys/{key_id}/certificate` downloads the certificate.
|
- `GET /api/v1/keys/{key_id}/certificate` downloads the certificate.
|
||||||
- `GET /api/v1/keys/{key_id}/certificate.sha256` downloads a sha256 hash file.
|
- `GET /api/v1/keys/{key_id}/certificate.sha256` downloads a sha256 hash file.
|
||||||
|
|
||||||
Agent endpoints (mTLS):
|
Agent endpoints (mTLS client certificate; cert is issued during enrollment, token retained only for migration compatibility):
|
||||||
- `GET /api/v1/agent/servers/{server_id}/ssh-ca` returns the CA public key for agent install.
|
- `GET /api/v1/agent/servers/{server_id}/ssh-ca` returns the CA public key for agent install.
|
||||||
- `GET /api/v1/agent/servers/{server_id}/accounts` returns account + system username (no raw keys).
|
- `GET /api/v1/agent/servers/{server_id}/accounts` returns account + system username (no raw keys).
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -9,7 +9,10 @@ ENV PYTHONUNBUFFERED=1 \
|
|||||||
PIP_DISABLE_PIP_VERSION_CHECK=on
|
PIP_DISABLE_PIP_VERSION_CHECK=on
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN sed -i \
|
||||||
|
-e 's|http://deb\.debian\.org/debian-security|http://security.debian.org/debian-security|g' \
|
||||||
|
-e 's|http://deb\.debian\.org/debian|http://ftp.uk.debian.org/debian|g' \
|
||||||
|
/etc/apt/sources.list.d/debian.sources
|
||||||
# System deps for psycopg2, node (for Tailwind), etc.
|
# System deps for psycopg2, node (for Tailwind), etc.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
|
|||||||
Binary file not shown.
+12
-15
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This guide configures:
|
This guide configures:
|
||||||
- OIDC client login (Authentik, Authelia, Keycloak, or other OIDC IdP)
|
- OIDC client login (Authentik, Authelia, Keycloak, or other OIDC IdP)
|
||||||
- Social login (Google, GitHub, Apple)
|
- Social login (GitHub)
|
||||||
|
|
||||||
Security model implemented by Keywarden:
|
Security model implemented by Keywarden:
|
||||||
- External identities are mapped by immutable provider subject (`sub`/`uid`) and provider ID.
|
- External identities are mapped by immutable provider subject (`sub`/`uid`) and provider ID.
|
||||||
@@ -120,25 +120,17 @@ KEYWARDEN_OIDC_ISSUER=https://id.example.com/realms/main
|
|||||||
KEYWARDEN_OIDC_GROUPS_CLAIM=groups
|
KEYWARDEN_OIDC_GROUPS_CLAIM=groups
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5. Social providers
|
## 5. GitHub social provider
|
||||||
|
|
||||||
Enable providers by credentials (or explicit `*_ENABLED=true`).
|
Enable GitHub by credentials (or explicit `*_ENABLED=true`).
|
||||||
|
|
||||||
```env
|
```env
|
||||||
KEYWARDEN_SITE_ID=1
|
KEYWARDEN_SITE_ID=1
|
||||||
KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL=true
|
KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL=true
|
||||||
|
|
||||||
KEYWARDEN_SOCIAL_GOOGLE_ENABLED=true
|
|
||||||
KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID=<google-client-id>
|
|
||||||
KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET=<google-secret>
|
|
||||||
|
|
||||||
KEYWARDEN_SOCIAL_GITHUB_ENABLED=true
|
KEYWARDEN_SOCIAL_GITHUB_ENABLED=true
|
||||||
KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID=<github-client-id>
|
KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID=<github-client-id>
|
||||||
KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET=<github-secret>
|
KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET=<github-secret>
|
||||||
|
|
||||||
KEYWARDEN_SOCIAL_APPLE_ENABLED=true
|
|
||||||
KEYWARDEN_SOCIAL_APPLE_CLIENT_ID=<apple-services-id>
|
|
||||||
KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET=<apple-client-secret-jwt>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Keywarden includes social auth URLs under:
|
Keywarden includes social auth URLs under:
|
||||||
@@ -147,10 +139,15 @@ Keywarden includes social auth URLs under:
|
|||||||
/accounts/sso/
|
/accounts/sso/
|
||||||
```
|
```
|
||||||
|
|
||||||
Provider callback URLs to register:
|
Provider callback URL to register:
|
||||||
- Google: `https://<your-keywarden-domain>/accounts/sso/google/login/callback/`
|
|
||||||
- GitHub: `https://<your-keywarden-domain>/accounts/sso/github/login/callback/`
|
- GitHub: `https://<your-keywarden-domain>/accounts/sso/github/login/callback/`
|
||||||
- Apple: `https://<your-keywarden-domain>/accounts/sso/apple/login/callback/`
|
|
||||||
|
Security behavior:
|
||||||
|
- Social auto-onboarding is disabled.
|
||||||
|
- Users must already exist in Keywarden and must link GitHub from `/accounts/profile/`.
|
||||||
|
- After linking, `Login with GitHub` is available as an additional login method.
|
||||||
|
- Local Keywarden email and GitHub email may differ; matching is by linked GitHub subject (`uid`).
|
||||||
|
- Native password reset/email verification are disabled for SSO-linked accounts and handled in the IdP.
|
||||||
|
|
||||||
## 6. Email immutability behavior
|
## 6. Email immutability behavior
|
||||||
|
|
||||||
@@ -180,6 +177,6 @@ When enabled, a previously promoted user is demoted to standard user if admin gr
|
|||||||
|
|
||||||
- Login page shows social buttons for enabled providers.
|
- Login page shows social buttons for enabled providers.
|
||||||
- `/oidc/authenticate/` starts IdP login.
|
- `/oidc/authenticate/` starts IdP login.
|
||||||
- First login auto-creates local user.
|
- First GitHub login only succeeds for previously linked users.
|
||||||
- Repeat logins map to same local user via external identity subject.
|
- Repeat logins map to same local user via external identity subject.
|
||||||
- Changing provider email for the same subject causes login rejection.
|
- Changing provider email for the same subject causes login rejection.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Logging Package Options
|
||||||
|
|
||||||
|
This agent already uses a library approach for journald (`github.com/coreos/go-systemd/v22/sdjournal`) and does not shell out to `journalctl` in normal collection paths.
|
||||||
|
|
||||||
|
## Current (keep)
|
||||||
|
|
||||||
|
- `github.com/coreos/go-systemd/v22/sdjournal`
|
||||||
|
- Direct journal access from Go.
|
||||||
|
- Cursor-based reads map well to incremental shipping.
|
||||||
|
- `github.com/coreos/go-systemd/v22/dbus` (candidate add-on)
|
||||||
|
- Read systemd unit state/metadata (`ListUnitsByNames`, unit properties) for richer service context.
|
||||||
|
|
||||||
|
## Good external collectors (if we want hardened third-party gathering)
|
||||||
|
|
||||||
|
- Fluent Bit
|
||||||
|
- `systemd` input plugin for journald, plus mature filtering/parsing/routing outputs.
|
||||||
|
- Strong choice when we want a small footprint daemon dedicated to log forwarding.
|
||||||
|
- Vector
|
||||||
|
- `journald` source + transforms (VRL) for compact line formatting before shipping.
|
||||||
|
- Good if we want expressive local transforms and multiple downstream sinks.
|
||||||
|
- OpenTelemetry Collector distributions with journald receiver support (for example Splunk OTel distro)
|
||||||
|
- Useful if we want logs + metrics + traces through one pipeline.
|
||||||
|
|
||||||
|
## Parser packages (when we need deeper line parsing)
|
||||||
|
|
||||||
|
- `github.com/influxdata/go-syslog/v3`
|
||||||
|
- RFC3164/RFC5424 syslog parsing for file or stream logs.
|
||||||
|
- `github.com/elastic/go-grok`
|
||||||
|
- Pattern-based parsing when message formats vary by service.
|
||||||
|
|
||||||
|
## Practical direction
|
||||||
|
|
||||||
|
1. Keep `go-systemd` for now and add optional `dbus` lookups for service status enrichment.
|
||||||
|
2. If we need host-agnostic, independently hardened collection, run Fluent Bit or Vector and ingest normalized JSON lines in the agent/server.
|
||||||
|
3. Reserve heavier parsing packages (`go-syslog`, `go-grok`) for specific source formats that require structured extraction beyond current compact line ingestion.
|
||||||
+78
-2
@@ -12,16 +12,92 @@ go build -o keywarden-agent ./cmd/keywarden-agent
|
|||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```
|
```
|
||||||
./keywarden-agent -config /etc/keywarden/agent.json -server-url https://keywarden.example.com -enroll-token <token>
|
./keywarden-agent --config /etc/keywarden/agent.json --server-url https://keywarden.example.com --enroll-token <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Short aliases are also supported:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./keywarden-agent -c /etc/keywarden/agent.json -t <token> -f
|
||||||
|
```
|
||||||
|
|
||||||
|
To rotate/re-enroll an existing agent identity in place, use `-f/--force-enroll` with a fresh token.
|
||||||
|
When `server_id` already exists in config, force-enroll rotates the client certificate for that server
|
||||||
|
instead of creating a new server record.
|
||||||
|
|
||||||
You can also pass `KEYWARDEN_SERVER_URL` and `KEYWARDEN_ENROLL_TOKEN` as environment variables.
|
You can also pass `KEYWARDEN_SERVER_URL` and `KEYWARDEN_ENROLL_TOKEN` as environment variables.
|
||||||
|
|
||||||
|
## systemd service (portable)
|
||||||
|
|
||||||
|
The repo includes a reusable unit file and installer:
|
||||||
|
|
||||||
|
- Unit: `systemd/keywarden-agent.service`
|
||||||
|
- Env template: `systemd/keywarden-agent.env.example`
|
||||||
|
- Installer: `scripts/install-systemd.sh`
|
||||||
|
|
||||||
|
### Quick install
|
||||||
|
|
||||||
|
From this `agent/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o keywarden-agent ./cmd/keywarden-agent
|
||||||
|
sudo ./scripts/install-systemd.sh
|
||||||
|
sudoedit /etc/default/keywarden-agent
|
||||||
|
sudo systemctl enable --now keywarden-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -D -m 0755 ./keywarden-agent /usr/local/bin/keywarden-agent
|
||||||
|
sudo install -D -m 0644 ./systemd/keywarden-agent.service /etc/systemd/system/keywarden-agent.service
|
||||||
|
sudo install -D -m 0640 ./systemd/keywarden-agent.env.example /etc/default/keywarden-agent
|
||||||
|
sudo mkdir -p /etc/keywarden
|
||||||
|
sudo bash -c 'cat > /etc/keywarden/agent.json <<EOF
|
||||||
|
{
|
||||||
|
"server_url": "https://keywarden.example.com/api/v1"
|
||||||
|
}
|
||||||
|
EOF'
|
||||||
|
sudo chmod 0600 /etc/keywarden/agent.json
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now keywarden-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### First boot behavior
|
||||||
|
|
||||||
|
- If `/etc/keywarden/agent.json` does not exist, set `KEYWARDEN_SERVER_URL` in `/etc/default/keywarden-agent`.
|
||||||
|
- For first enrollment, set `KEYWARDEN_ENROLL_TOKEN` in `/etc/default/keywarden-agent`.
|
||||||
|
- After successful enrollment, `server_id` and `agent_api_token` are written to `agent.json`.
|
||||||
|
- You can remove `KEYWARDEN_ENROLL_TOKEN` from `/etc/default/keywarden-agent` after enrollment.
|
||||||
|
|
||||||
|
### Service operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status keywarden-agent
|
||||||
|
sudo journalctl -u keywarden-agent -f
|
||||||
|
sudo systemctl restart keywarden-agent
|
||||||
|
```
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
On first boot, the agent will create a config file if it does not exist. Only `server_url` is required for bootstrapping.
|
On first boot, the agent will create a config file if it does not exist. Only `server_url` is required for bootstrapping.
|
||||||
|
Do not prefill `server_id` or `agent_api_token` with placeholders; those are written by enrollment.
|
||||||
|
|
||||||
If the Keywarden server uses a private TLS CA, set `server_ca_path` (or `KEYWARDEN_SERVER_CA_PATH`) to the CA PEM file so the agent can verify the server certificate.
|
If the Keywarden server uses a private TLS CA, set `server_ca_path` (or `KEYWARDEN_SERVER_CA_PATH`) to the CA PEM file so the agent can verify the server certificate.
|
||||||
|
`agent_api_token` is issued by `/agent/enroll` and persisted into `agent.json` automatically.
|
||||||
|
Runtime authentication is mTLS-first; `agent_api_token` is retained for backward compatibility during migration.
|
||||||
|
If used, supply the raw token value (no `Bearer ` prefix). If `KEYWARDEN_AGENT_API_TOKEN` env var is set, it overrides `agent_api_token` in `agent.json`.
|
||||||
|
When no `log_sources` are configured, the agent defaults to journald collection via `go-systemd/sdjournal` and backfills from the start of the current boot on first sync.
|
||||||
|
If journald is unavailable, the agent falls back to common system log files (`/var/log/auth.log`, `/var/log/secure`, `/var/log/syslog`, `/var/log/messages`).
|
||||||
|
|
||||||
|
`log_sources` can be defined locally for service/file monitors. These are used as:
|
||||||
|
- fallback if central `/agent/servers/{id}/log-config` cannot be fetched
|
||||||
|
- additive sources when central config defines one or more explicit sources
|
||||||
|
|
||||||
|
If central `log-config` returns an empty list, the agent uses default journald
|
||||||
|
collection from the current boot and ignores local `log_sources` for that run.
|
||||||
|
|
||||||
See `config.example.json`.
|
See `config.example.json`.
|
||||||
|
|
||||||
|
For package alternatives and external collector options, see `LOGGING_PACKAGES.md`.
|
||||||
@@ -4,15 +4,20 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -23,10 +28,16 @@ import (
|
|||||||
"keywarden/agent/internal/version"
|
"keywarden/agent/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var lastNoEventsNotice time.Time
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", config.DefaultConfigPath, "Path to agent config JSON")
|
configPath := flag.String("config", config.DefaultConfigPath, "Path to agent config JSON")
|
||||||
|
flag.StringVar(configPath, "c", config.DefaultConfigPath, "Path to agent config JSON (shorthand)")
|
||||||
serverURL := flag.String("server-url", "", "Keywarden server URL (first boot)")
|
serverURL := flag.String("server-url", "", "Keywarden server URL (first boot)")
|
||||||
enrollToken := flag.String("enroll-token", "", "Enrollment token (first boot)")
|
enrollToken := flag.String("enroll-token", "", "Enrollment token (first boot)")
|
||||||
|
flag.StringVar(enrollToken, "t", "", "Enrollment token (first boot, shorthand)")
|
||||||
|
forceEnroll := flag.Bool("force-enroll", false, "Force re-enrollment even if already bootstrapped")
|
||||||
|
flag.BoolVar(forceEnroll, "f", false, "Force re-enrollment even if already bootstrapped (shorthand)")
|
||||||
showVersion := flag.Bool("version", false, "Print version and exit")
|
showVersion := flag.Bool("version", false, "Print version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -43,7 +54,7 @@ func main() {
|
|||||||
log.Fatalf("state dir error: %v", err)
|
log.Fatalf("state dir error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bootstrapIfNeeded(cfg, *configPath, pickEnrollToken(*enrollToken)); err != nil {
|
if err := bootstrapIfNeeded(cfg, *configPath, pickEnrollToken(*enrollToken), *forceEnroll); err != nil {
|
||||||
log.Fatalf("bootstrap error: %v", err)
|
log.Fatalf("bootstrap error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,20 +91,32 @@ func runOnce(ctx context.Context, apiClient *client.Client, cfg *config.Config)
|
|||||||
log.Printf("host update deferred; will retry: %v", err)
|
log.Printf("host update deferred; will retry: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("host update error: %v", err)
|
log.Printf("host update error: %v", err)
|
||||||
|
logAgentUnauthorizedHint(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := apiClient.SyncAccounts(ctx, cfg); err != nil {
|
if err := apiClient.SyncAccounts(ctx, cfg); err != nil {
|
||||||
log.Printf("sync accounts error: %v", err)
|
log.Printf("sync accounts error: %v", err)
|
||||||
|
logAgentUnauthorizedHint(err)
|
||||||
}
|
}
|
||||||
if err := shipLogs(ctx, apiClient, cfg); err != nil {
|
if err := shipLogs(ctx, apiClient, cfg); err != nil {
|
||||||
if client.IsRetriable(err) {
|
if client.IsRetriable(err) {
|
||||||
log.Printf("log shipping deferred; will retry: %v", err)
|
log.Printf("log shipping deferred; will retry: %v", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("log shipping error: %v", err)
|
log.Printf("log shipping error: %v", err)
|
||||||
|
logAgentUnauthorizedHint(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logAgentUnauthorizedHint(err error) {
|
||||||
|
var statusErr *client.HTTPStatusError
|
||||||
|
if errors.As(err, &statusErr) && statusErr.StatusCode == 401 {
|
||||||
|
log.Printf(
|
||||||
|
"agent auth failed (401): runtime auth is mTLS-first; verify this agent is using the enrolled client cert/key, server_id matches enrollment, and traffic is routed through nginx /api/v1/agent/ so TLS client-cert headers are forwarded. Bearer agent_api_token is optional compatibility metadata, not the primary auth path.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func ensureDirs(cfg *config.Config) error {
|
func ensureDirs(cfg *config.Config) error {
|
||||||
if err := os.MkdirAll(cfg.StateDir, 0o700); err != nil {
|
if err := os.MkdirAll(cfg.StateDir, 0o700); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -114,16 +137,56 @@ func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor, err := logs.ReadCursor(cfg.LogCursorPath())
|
state, err := logs.ReadState(cfg.LogStatePath())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
localSources := toLogSourcesFromConfig(cfg.EnabledLogSources())
|
||||||
|
sourceConfig, err := apiClient.FetchLogConfig(ctx, cfg.ServerID)
|
||||||
|
var sources []logs.SourceConfig
|
||||||
|
if err != nil {
|
||||||
|
if len(localSources) == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("log config fetch failed; using local log_sources fallback: %v", err)
|
||||||
|
sources = localSources
|
||||||
|
} else {
|
||||||
|
sources = toLogSources(sourceConfig)
|
||||||
|
if len(sources) > 0 && len(localSources) > 0 {
|
||||||
|
sources = append(sources, localSources...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state, err = reconcileLogState(cfg, state, sources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
collector := logs.NewCollector()
|
collector := logs.NewCollector()
|
||||||
events, nextCursor, err := collector.Collect(ctx, cursor, cfg.LogBatchSize)
|
events, nextState, err := collector.Collect(ctx, cfg.LogBatchSize, sources, state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
if len(sources) > 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fallbackSources := defaultFileFallbackSources()
|
||||||
|
log.Printf("default journald collection failed; falling back to file monitors: %v", err)
|
||||||
|
events, nextState, err = collector.Collect(ctx, cfg.LogBatchSize, fallbackSources, state)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(events) == 0 && len(sources) == 0 {
|
||||||
|
fallbackSources := defaultFileFallbackSources()
|
||||||
|
log.Printf("default journald yielded no events; trying file monitors")
|
||||||
|
fileEvents, fileState, fileErr := collector.Collect(ctx, cfg.LogBatchSize, fallbackSources, nextState)
|
||||||
|
if fileErr != nil {
|
||||||
|
return fileErr
|
||||||
|
}
|
||||||
|
events = fileEvents
|
||||||
|
nextState = fileState
|
||||||
}
|
}
|
||||||
if len(events) == 0 {
|
if len(events) == 0 {
|
||||||
|
if maybeLogNoEventsNotice(len(sources)) && len(sources) == 0 {
|
||||||
|
log.Printf("log shipping file fallback probe: %s", describeFileSourceAvailability(defaultFileFallbackSources()))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
payload, err := json.Marshal(events)
|
payload, err := json.Marshal(events)
|
||||||
@@ -136,7 +199,7 @@ func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config)
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := logs.WriteCursor(cfg.LogCursorPath(), nextCursor); err != nil {
|
if err := logs.WriteState(cfg.LogStatePath(), nextState); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -172,12 +235,25 @@ func pickEnrollToken(flagValue string) string {
|
|||||||
return os.Getenv("KEYWARDEN_ENROLL_TOKEN")
|
return os.Getenv("KEYWARDEN_ENROLL_TOKEN")
|
||||||
}
|
}
|
||||||
|
|
||||||
func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string) error {
|
func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string, forceEnroll bool) error {
|
||||||
if cfg.ServerID != "" && fileExists(cfg.ClientCertPath()) && fileExists(cfg.CACertPath()) {
|
previousServerID := strings.TrimSpace(cfg.ServerID)
|
||||||
|
if !forceEnroll && cfg.ServerID != "" && fileExists(cfg.ClientCertPath()) && fileExists(cfg.CACertPath()) {
|
||||||
|
if strings.TrimSpace(enrollToken) != "" {
|
||||||
|
log.Printf("enrollment token provided but bootstrap is already complete; skipping enrollment (use -f/--force-enroll to rotate identity)")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if enrollToken == "" {
|
if enrollToken == "" {
|
||||||
return fmt.Errorf("missing enrollment token; set KEYWARDEN_ENROLL_TOKEN or -enroll-token")
|
return fmt.Errorf("missing enrollment token; set KEYWARDEN_ENROLL_TOKEN or -t/--enroll-token")
|
||||||
|
}
|
||||||
|
rotateServerID := ""
|
||||||
|
if forceEnroll {
|
||||||
|
rotateServerID = previousServerID
|
||||||
|
if rotateServerID != "" {
|
||||||
|
log.Printf("force re-enrollment requested; rotating certificate in-place for server_id=%s", rotateServerID)
|
||||||
|
} else {
|
||||||
|
log.Printf("force re-enrollment requested; no existing server_id, enrolling as new server identity")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
keyPath := cfg.ClientKeyPath()
|
keyPath := cfg.ClientKeyPath()
|
||||||
if !fileExists(keyPath) {
|
if !fileExists(keyPath) {
|
||||||
@@ -192,15 +268,23 @@ func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string
|
|||||||
info := host.Detect()
|
info := host.Detect()
|
||||||
hostname := info.Hostname
|
hostname := info.Hostname
|
||||||
resp, err := client.Enroll(context.Background(), cfg.ServerURL, client.EnrollRequest{
|
resp, err := client.Enroll(context.Background(), cfg.ServerURL, client.EnrollRequest{
|
||||||
Token: enrollToken,
|
Token: enrollToken,
|
||||||
CSRPEM: csrPEM,
|
CSRPEM: csrPEM,
|
||||||
Host: hostname,
|
Host: hostname,
|
||||||
IPv4: info.IPv4,
|
IPv4: info.IPv4,
|
||||||
IPv6: info.IPv6,
|
IPv6: info.IPv6,
|
||||||
|
ServerID: rotateServerID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if rotateServerID != "" && strings.TrimSpace(resp.ServerID) != rotateServerID {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"re-enrollment returned unexpected server_id=%s (expected %s)",
|
||||||
|
strings.TrimSpace(resp.ServerID),
|
||||||
|
rotateServerID,
|
||||||
|
)
|
||||||
|
}
|
||||||
if err := os.WriteFile(cfg.ClientCertPath(), []byte(resp.ClientCert), 0o600); err != nil {
|
if err := os.WriteFile(cfg.ClientCertPath(), []byte(resp.ClientCert), 0o600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -208,12 +292,156 @@ func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cfg.ServerID = resp.ServerID
|
cfg.ServerID = resp.ServerID
|
||||||
|
if token := strings.TrimSpace(resp.AgentToken); token != "" {
|
||||||
|
cfg.AgentAPIToken = token
|
||||||
|
}
|
||||||
|
if forceEnroll || (previousServerID != "" && previousServerID != cfg.ServerID) {
|
||||||
|
if err := resetLogState(cfg); err != nil {
|
||||||
|
return fmt.Errorf("reset log state: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := config.Save(configPath, cfg); err != nil {
|
if err := config.Save(configPath, cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resetLogState(cfg *config.Config) error {
|
||||||
|
if err := os.Remove(cfg.LogCursorPath()); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Remove(cfg.LogOffsetsPath()); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Remove(cfg.LogStatePath()); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Remove(logSourceStatePath(cfg)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(cfg.LogSpoolDir()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.MkdirAll(cfg.LogSpoolDir(), 0o700)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultFileFallbackSources() []logs.SourceConfig {
|
||||||
|
return []logs.SourceConfig{
|
||||||
|
{Kind: "file", Name: "/var/log/auth.log", FilePath: "/var/log/auth.log", Category: "auth", EventType: "file.line", Parser: "syslog"},
|
||||||
|
{Kind: "file", Name: "/var/log/secure", FilePath: "/var/log/secure", Category: "auth", EventType: "file.line", Parser: "syslog"},
|
||||||
|
{Kind: "file", Name: "/var/log/syslog", FilePath: "/var/log/syslog", Category: "system", EventType: "file.line", Parser: "syslog"},
|
||||||
|
{Kind: "file", Name: "/var/log/messages", FilePath: "/var/log/messages", Category: "system", EventType: "file.line", Parser: "syslog"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func maybeLogNoEventsNotice(sourceCount int) bool {
|
||||||
|
now := time.Now()
|
||||||
|
if !lastNoEventsNotice.IsZero() && now.Sub(lastNoEventsNotice) < time.Minute {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mode := "default journald (current boot)"
|
||||||
|
if sourceCount > 0 {
|
||||||
|
mode = "configured sources"
|
||||||
|
}
|
||||||
|
log.Printf("log shipping: no events collected (%s)", mode)
|
||||||
|
lastNoEventsNotice = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func describeFileSourceAvailability(sources []logs.SourceConfig) string {
|
||||||
|
items := make([]string, 0, len(sources))
|
||||||
|
for _, source := range sources {
|
||||||
|
path := strings.TrimSpace(source.FilePath)
|
||||||
|
if path == "" {
|
||||||
|
path = strings.TrimSpace(source.Name)
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := path + ": "
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
items = append(items, item+"missing")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, item+err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
items = append(items, item+"is directory")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
file, openErr := os.Open(path)
|
||||||
|
if openErr != nil {
|
||||||
|
items = append(items, item+openErr.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = file.Close()
|
||||||
|
items = append(items, fmt.Sprintf("%d bytes readable", info.Size()))
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "no file sources"
|
||||||
|
}
|
||||||
|
return strings.Join(items, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func reconcileLogState(
|
||||||
|
cfg *config.Config,
|
||||||
|
state map[string]logs.SourceState,
|
||||||
|
sources []logs.SourceConfig,
|
||||||
|
) (map[string]logs.SourceState, error) {
|
||||||
|
statePath := logSourceStatePath(cfg)
|
||||||
|
previous, err := os.ReadFile(statePath)
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return state, err
|
||||||
|
}
|
||||||
|
previousSig := strings.TrimSpace(string(previous))
|
||||||
|
currentSig := logSourceSignature(sources)
|
||||||
|
needsReset := false
|
||||||
|
if previousSig == "" {
|
||||||
|
if len(state) > 0 {
|
||||||
|
log.Printf("log state signature missing; resetting per-source state for fresh collection")
|
||||||
|
needsReset = true
|
||||||
|
}
|
||||||
|
} else if previousSig != currentSig {
|
||||||
|
log.Printf("log sources changed; resetting per-source state for fresh collection")
|
||||||
|
needsReset = true
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(statePath, []byte(currentSig+"\n"), 0o600); err != nil {
|
||||||
|
return state, err
|
||||||
|
}
|
||||||
|
if needsReset {
|
||||||
|
return map[string]logs.SourceState{}, nil
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func logSourceStatePath(cfg *config.Config) string {
|
||||||
|
return cfg.StateDir + "/log.sources.sha256"
|
||||||
|
}
|
||||||
|
|
||||||
|
func logSourceSignature(sources []logs.SourceConfig) string {
|
||||||
|
parts := make([]string, 0, len(sources))
|
||||||
|
for _, source := range sources {
|
||||||
|
parts = append(parts, strings.Join([]string{
|
||||||
|
strings.TrimSpace(source.SourceID),
|
||||||
|
strings.ToLower(strings.TrimSpace(source.Kind)),
|
||||||
|
strings.TrimSpace(source.Name),
|
||||||
|
strings.TrimSpace(source.ServiceUnit),
|
||||||
|
strings.TrimSpace(source.FilePath),
|
||||||
|
strings.ToLower(strings.TrimSpace(source.Parser)),
|
||||||
|
strings.TrimSpace(source.Category),
|
||||||
|
strings.TrimSpace(source.EventType),
|
||||||
|
encodeMatchMap(source.IncludeMatches),
|
||||||
|
encodeMatchMap(source.ExcludeMatches),
|
||||||
|
}, "\x1f"))
|
||||||
|
}
|
||||||
|
sort.Strings(parts)
|
||||||
|
sum := sha256.Sum256([]byte(strings.Join(parts, "\x1e")))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
func retry(ctx context.Context, delays []time.Duration, fn func() error) error {
|
func retry(ctx context.Context, delays []time.Duration, fn func() error) error {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt <= len(delays); attempt++ {
|
for attempt := 0; attempt <= len(delays); attempt++ {
|
||||||
@@ -276,3 +504,59 @@ func fileExists(path string) bool {
|
|||||||
}
|
}
|
||||||
return !info.IsDir()
|
return !info.IsDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toLogSources(in []client.LogSourceConfig) []logs.SourceConfig {
|
||||||
|
out := make([]logs.SourceConfig, 0, len(in))
|
||||||
|
for _, source := range in {
|
||||||
|
out = append(out, logs.SourceConfig{
|
||||||
|
SourceID: source.SourceID,
|
||||||
|
Kind: source.Kind,
|
||||||
|
Name: source.Name,
|
||||||
|
ServiceUnit: source.ServiceUnit,
|
||||||
|
FilePath: source.FilePath,
|
||||||
|
Parser: source.Parser,
|
||||||
|
IncludeMatches: source.IncludeMatches,
|
||||||
|
ExcludeMatches: source.ExcludeMatches,
|
||||||
|
Category: source.Category,
|
||||||
|
EventType: source.EventType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toLogSourcesFromConfig(in []config.LogSource) []logs.SourceConfig {
|
||||||
|
out := make([]logs.SourceConfig, 0, len(in))
|
||||||
|
for _, source := range in {
|
||||||
|
out = append(out, logs.SourceConfig{
|
||||||
|
SourceID: source.SourceID,
|
||||||
|
Kind: source.Kind,
|
||||||
|
Name: source.Name,
|
||||||
|
ServiceUnit: source.ServiceUnit,
|
||||||
|
FilePath: source.FilePath,
|
||||||
|
Parser: source.Parser,
|
||||||
|
IncludeMatches: source.IncludeMatches,
|
||||||
|
ExcludeMatches: source.ExcludeMatches,
|
||||||
|
Category: source.Category,
|
||||||
|
EventType: source.EventType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeMatchMap(in map[string][]string) string {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(in))
|
||||||
|
for key := range in {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
values := append([]string{}, in[key]...)
|
||||||
|
sort.Strings(values)
|
||||||
|
parts = append(parts, key+"="+strings.Join(values, ","))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ";")
|
||||||
|
}
|
||||||
@@ -1,9 +1,59 @@
|
|||||||
{
|
{
|
||||||
"server_url": "https://keywarden.dev.ntbx.io/api/v1",
|
"server_url": "https://keywarden.example.com/api/v1",
|
||||||
"server_id": "4",
|
"server_id": "6",
|
||||||
"server_ca_path": "",
|
"agent_api_token": "HV8MnbysmINShu9wkXzzNrQE5NyRlPJHfP2aU6SX4HERrlac8EQpoMEu02FX1TYv",
|
||||||
"sync_interval_seconds": 5,
|
"sync_interval_seconds": 5,
|
||||||
"log_batch_size": 500,
|
"log_batch_size": 2000,
|
||||||
|
"log_sources": [
|
||||||
|
{
|
||||||
|
"source_id": "journal-all",
|
||||||
|
"kind": "journal",
|
||||||
|
"name": "All Journal",
|
||||||
|
"category": "system",
|
||||||
|
"event_type": "system",
|
||||||
|
"parser": "none",
|
||||||
|
"include_matches": {},
|
||||||
|
"exclude_matches": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_id": "journal-kernel",
|
||||||
|
"kind": "journal",
|
||||||
|
"name": "Kernel Journal",
|
||||||
|
"category": "system",
|
||||||
|
"event_type": "kernel",
|
||||||
|
"parser": "none",
|
||||||
|
"include_matches": {
|
||||||
|
"_TRANSPORT": ["kernel"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_id": "service-ssh",
|
||||||
|
"kind": "service",
|
||||||
|
"name": "OpenSSH",
|
||||||
|
"service_unit": "ssh.service",
|
||||||
|
"category": "access",
|
||||||
|
"event_type": "ssh",
|
||||||
|
"parser": "none"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_id": "file-auth",
|
||||||
|
"kind": "file",
|
||||||
|
"name": "Auth Log",
|
||||||
|
"file_path": "/var/log/auth.log",
|
||||||
|
"category": "auth",
|
||||||
|
"event_type": "file.line",
|
||||||
|
"parser": "syslog"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_id": "file-nginx-access",
|
||||||
|
"kind": "file",
|
||||||
|
"name": "Nginx Access",
|
||||||
|
"file_path": "/var/log/nginx/access.log",
|
||||||
|
"category": "access",
|
||||||
|
"event_type": "http.access",
|
||||||
|
"parser": "nginx_access"
|
||||||
|
}
|
||||||
|
],
|
||||||
"state_dir": "/var/lib/keywarden-agent",
|
"state_dir": "/var/lib/keywarden-agent",
|
||||||
"account_policy": {
|
"account_policy": {
|
||||||
"username_template": "{{username}}_{{user_id}}",
|
"username_template": "{{username}}_{{user_id}}",
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"server_url": "https://agent.keywarden.dev.ntbx.io/api/v1",
|
||||||
|
"server_id": "13",
|
||||||
|
"sync_interval_seconds": 5,
|
||||||
|
"log_batch_size": 2000,
|
||||||
|
"state_dir": "/var/lib/keywarden-agent",
|
||||||
|
"account_policy": {
|
||||||
|
"username_template": "{{username}}_{{user_id}}",
|
||||||
|
"default_shell": "/bin/bash",
|
||||||
|
"admin_group": "sudo",
|
||||||
|
"create_home": true,
|
||||||
|
"lock_on_revoke": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,18 @@ type Client struct {
|
|||||||
scheme string
|
scheme string
|
||||||
host string
|
host string
|
||||||
addr string
|
addr string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAgentToken(raw string) string {
|
||||||
|
token := strings.TrimSpace(raw)
|
||||||
|
if token == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") {
|
||||||
|
token = strings.TrimSpace(token[7:])
|
||||||
|
}
|
||||||
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config) (*Client, error) {
|
func New(cfg *config.Config) (*Client, error) {
|
||||||
@@ -35,10 +47,12 @@ func New(cfg *config.Config) (*Client, error) {
|
|||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
return nil, errors.New("server url is required")
|
return nil, errors.New("server url is required")
|
||||||
}
|
}
|
||||||
|
agentToken := normalizeAgentToken(cfg.AgentAPIToken)
|
||||||
cert, err := tls.LoadX509KeyPair(cfg.ClientCertPath(), cfg.ClientKeyPath())
|
cert, err := tls.LoadX509KeyPair(cfg.ClientCertPath(), cfg.ClientKeyPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load client cert: %w", err)
|
return nil, fmt.Errorf("load client cert: %w", err)
|
||||||
}
|
}
|
||||||
|
clientCert := cert
|
||||||
caPool, err := x509.SystemCertPool()
|
caPool, err := x509.SystemCertPool()
|
||||||
if err != nil || caPool == nil {
|
if err != nil || caPool == nil {
|
||||||
caPool = x509.NewCertPool()
|
caPool = x509.NewCertPool()
|
||||||
@@ -57,6 +71,12 @@ func New(cfg *config.Config) (*Client, error) {
|
|||||||
Certificates: []tls.Certificate{cert},
|
Certificates: []tls.Certificate{cert},
|
||||||
RootCAs: caPool,
|
RootCAs: caPool,
|
||||||
MinVersion: tls.VersionTLS12,
|
MinVersion: tls.VersionTLS12,
|
||||||
|
// Always return the enrolled client certificate when the server
|
||||||
|
// requests mTLS credentials. This avoids edge cases where automatic
|
||||||
|
// certificate selection declines to send a cert.
|
||||||
|
GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
|
||||||
|
return &clientCert, nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
@@ -97,22 +117,32 @@ func New(cfg *config.Config) (*Client, error) {
|
|||||||
scheme: scheme,
|
scheme: scheme,
|
||||||
host: host,
|
host: host,
|
||||||
addr: addr,
|
addr: addr,
|
||||||
|
token: agentToken,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) setAuth(req *http.Request) {
|
||||||
|
if c.token == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||||
|
}
|
||||||
|
|
||||||
type EnrollRequest struct {
|
type EnrollRequest struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
CSRPEM string `json:"csr_pem"`
|
CSRPEM string `json:"csr_pem"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
IPv4 string `json:"ipv4,omitempty"`
|
IPv4 string `json:"ipv4,omitempty"`
|
||||||
IPv6 string `json:"ipv6,omitempty"`
|
IPv6 string `json:"ipv6,omitempty"`
|
||||||
AgentID string `json:"agent_id,omitempty"`
|
ServerID string `json:"server_id,omitempty"`
|
||||||
|
AgentID string `json:"agent_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnrollResponse struct {
|
type EnrollResponse struct {
|
||||||
ServerID string `json:"server_id"`
|
ServerID string `json:"server_id"`
|
||||||
ClientCert string `json:"client_cert_pem"`
|
ClientCert string `json:"client_cert_pem"`
|
||||||
CACert string `json:"ca_cert_pem"`
|
CACert string `json:"ca_cert_pem"`
|
||||||
|
AgentToken string `json:"agent_api_token,omitempty"`
|
||||||
SyncProfile string `json:"sync_profile,omitempty"`
|
SyncProfile string `json:"sync_profile,omitempty"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -149,6 +179,19 @@ type SyncReportRequest struct {
|
|||||||
Accounts []AccountSyncEntry `json:"accounts,omitempty"`
|
Accounts []AccountSyncEntry `json:"accounts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogSourceConfig struct {
|
||||||
|
SourceID string `json:"source_id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ServiceUnit string `json:"service_unit,omitempty"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
Parser string `json:"parser,omitempty"`
|
||||||
|
IncludeMatches map[string][]string `json:"include_matches,omitempty"`
|
||||||
|
ExcludeMatches map[string][]string `json:"exclude_matches,omitempty"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
}
|
||||||
|
|
||||||
func Enroll(ctx context.Context, serverURL string, req EnrollRequest) (*EnrollResponse, error) {
|
func Enroll(ctx context.Context, serverURL string, req EnrollRequest) (*EnrollResponse, error) {
|
||||||
baseURL := strings.TrimRight(serverURL, "/")
|
baseURL := strings.TrimRight(serverURL, "/")
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
@@ -241,6 +284,7 @@ func (c *Client) FetchAccountAccess(ctx context.Context, serverID string) ([]Acc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build account access request: %w", err)
|
return nil, fmt.Errorf("build account access request: %w", err)
|
||||||
}
|
}
|
||||||
|
c.setAuth(req)
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetch account access: %w", err)
|
return nil, fmt.Errorf("fetch account access: %w", err)
|
||||||
@@ -266,6 +310,7 @@ func (c *Client) FetchUserCA(ctx context.Context, serverID string) (*UserCARespo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build user ca request: %w", err)
|
return nil, fmt.Errorf("build user ca request: %w", err)
|
||||||
}
|
}
|
||||||
|
c.setAuth(req)
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetch user ca: %w", err)
|
return nil, fmt.Errorf("fetch user ca: %w", err)
|
||||||
@@ -299,6 +344,7 @@ func (c *Client) SendSyncReport(ctx context.Context, serverID string, report Syn
|
|||||||
return fmt.Errorf("build sync report: %w", err)
|
return fmt.Errorf("build sync report: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
c.setAuth(req)
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("send sync report: %w", err)
|
return fmt.Errorf("send sync report: %w", err)
|
||||||
@@ -310,12 +356,39 @@ func (c *Client) SendSyncReport(ctx context.Context, serverID string, report Syn
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) FetchLogConfig(ctx context.Context, serverID string) ([]LogSourceConfig, error) {
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
http.MethodGet,
|
||||||
|
c.baseURL+"/agent/servers/"+serverID+"/log-config",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build log config request: %w", err)
|
||||||
|
}
|
||||||
|
c.setAuth(req)
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch log config: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return nil, &HTTPStatusError{StatusCode: resp.StatusCode, Status: resp.Status}
|
||||||
|
}
|
||||||
|
var out []LogSourceConfig
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode log config: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) SendLogBatch(ctx context.Context, serverID string, payload []byte) error {
|
func (c *Client) SendLogBatch(ctx context.Context, serverID string, payload []byte) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/agent/servers/"+serverID+"/logs", bytes.NewReader(payload))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/agent/servers/"+serverID+"/logs", bytes.NewReader(payload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build log request: %w", err)
|
return fmt.Errorf("build log request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
c.setAuth(req)
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("send log batch: %w", err)
|
return fmt.Errorf("send log batch: %w", err)
|
||||||
@@ -344,6 +417,7 @@ func (c *Client) UpdateHost(ctx context.Context, serverID string, reqBody Heartb
|
|||||||
return fmt.Errorf("build host update: %w", err)
|
return fmt.Errorf("build host update: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
c.setAuth(req)
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("send host update: %w", err)
|
return fmt.Errorf("send host update: %w", err)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,12 +27,28 @@ type AccountPolicy struct {
|
|||||||
LockOnRevoke bool `json:"lock_on_revoke"`
|
LockOnRevoke bool `json:"lock_on_revoke"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogSource struct {
|
||||||
|
SourceID string `json:"source_id,omitempty"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
ServiceUnit string `json:"service_unit,omitempty"`
|
||||||
|
FilePath string `json:"file_path,omitempty"`
|
||||||
|
Parser string `json:"parser,omitempty"`
|
||||||
|
IncludeMatches map[string][]string `json:"include_matches,omitempty"`
|
||||||
|
ExcludeMatches map[string][]string `json:"exclude_matches,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
EventType string `json:"event_type,omitempty"`
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ServerURL string `json:"server_url"`
|
ServerURL string `json:"server_url"`
|
||||||
ServerID string `json:"server_id,omitempty"`
|
ServerID string `json:"server_id,omitempty"`
|
||||||
|
AgentAPIToken string `json:"agent_api_token,omitempty"`
|
||||||
ServerCAPath string `json:"server_ca_path,omitempty"`
|
ServerCAPath string `json:"server_ca_path,omitempty"`
|
||||||
SyncIntervalSeconds int `json:"sync_interval_seconds,omitempty"`
|
SyncIntervalSeconds int `json:"sync_interval_seconds,omitempty"`
|
||||||
LogBatchSize int `json:"log_batch_size,omitempty"`
|
LogBatchSize int `json:"log_batch_size,omitempty"`
|
||||||
|
LogSources []LogSource `json:"log_sources,omitempty"`
|
||||||
StateDir string `json:"state_dir,omitempty"`
|
StateDir string `json:"state_dir,omitempty"`
|
||||||
AccountPolicy AccountPolicy `json:"account_policy,omitempty"`
|
AccountPolicy AccountPolicy `json:"account_policy,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -48,7 +65,11 @@ func LoadOrInit(path string, serverURL string) (*Config, error) {
|
|||||||
if serverURL == "" {
|
if serverURL == "" {
|
||||||
return nil, errors.New("server url required for first boot")
|
return nil, errors.New("server url required for first boot")
|
||||||
}
|
}
|
||||||
cfg := &Config{ServerURL: serverURL, ServerCAPath: os.Getenv("KEYWARDEN_SERVER_CA_PATH")}
|
cfg := &Config{
|
||||||
|
ServerURL: serverURL,
|
||||||
|
AgentAPIToken: os.Getenv("KEYWARDEN_AGENT_API_TOKEN"),
|
||||||
|
ServerCAPath: os.Getenv("KEYWARDEN_SERVER_CA_PATH"),
|
||||||
|
}
|
||||||
applyDefaults(cfg)
|
applyDefaults(cfg)
|
||||||
if err := validate(cfg, false); err != nil {
|
if err := validate(cfg, false); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -62,9 +83,15 @@ func LoadOrInit(path string, serverURL string) (*Config, error) {
|
|||||||
if err := json.Unmarshal(data, cfg); err != nil {
|
if err := json.Unmarshal(data, cfg); err != nil {
|
||||||
return nil, fmt.Errorf("parse config: %w", err)
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
}
|
}
|
||||||
|
envAgentToken := strings.TrimSpace(os.Getenv("KEYWARDEN_AGENT_API_TOKEN"))
|
||||||
if cfg.ServerCAPath == "" {
|
if cfg.ServerCAPath == "" {
|
||||||
cfg.ServerCAPath = os.Getenv("KEYWARDEN_SERVER_CA_PATH")
|
cfg.ServerCAPath = os.Getenv("KEYWARDEN_SERVER_CA_PATH")
|
||||||
}
|
}
|
||||||
|
// Environment should win when set so operators can rotate tokens centrally
|
||||||
|
// without editing persisted agent.json on every node.
|
||||||
|
if envAgentToken != "" {
|
||||||
|
cfg.AgentAPIToken = envAgentToken
|
||||||
|
}
|
||||||
applyDefaults(cfg)
|
applyDefaults(cfg)
|
||||||
if err := validate(cfg, false); err != nil {
|
if err := validate(cfg, false); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -108,6 +135,8 @@ func applyDefaults(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validate(cfg *Config, requireServerID bool) error {
|
func validate(cfg *Config, requireServerID bool) error {
|
||||||
|
cfg.ServerID = strings.TrimSpace(cfg.ServerID)
|
||||||
|
cfg.AgentAPIToken = strings.TrimSpace(cfg.AgentAPIToken)
|
||||||
var missing []string
|
var missing []string
|
||||||
if cfg.ServerURL == "" {
|
if cfg.ServerURL == "" {
|
||||||
missing = append(missing, "server_url")
|
missing = append(missing, "server_url")
|
||||||
@@ -118,12 +147,57 @@ func validate(cfg *Config, requireServerID bool) error {
|
|||||||
if len(missing) > 0 {
|
if len(missing) > 0 {
|
||||||
return fmt.Errorf("missing required config fields: %v", missing)
|
return fmt.Errorf("missing required config fields: %v", missing)
|
||||||
}
|
}
|
||||||
|
if looksLikePlaceholder(cfg.ServerID) {
|
||||||
|
return errors.New("invalid server_id placeholder in config; remove server_id and re-enroll the agent")
|
||||||
|
}
|
||||||
|
if cfg.ServerID != "" {
|
||||||
|
if _, err := strconv.Atoi(cfg.ServerID); err != nil {
|
||||||
|
return errors.New("invalid server_id in config; expected numeric server id from enrollment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if looksLikePlaceholder(cfg.AgentAPIToken) {
|
||||||
|
return errors.New("invalid agent_api_token placeholder in config; remove agent_api_token and re-enroll the agent")
|
||||||
|
}
|
||||||
if cfg.SyncIntervalSeconds < 5 {
|
if cfg.SyncIntervalSeconds < 5 {
|
||||||
return errors.New("sync_interval_seconds must be >= 5")
|
return errors.New("sync_interval_seconds must be >= 5")
|
||||||
}
|
}
|
||||||
|
for i, source := range cfg.LogSources {
|
||||||
|
kind := strings.ToLower(strings.TrimSpace(source.Kind))
|
||||||
|
switch kind {
|
||||||
|
case "journal":
|
||||||
|
// journal source is valid with optional include/exclude matches.
|
||||||
|
case "service":
|
||||||
|
if strings.TrimSpace(source.ServiceUnit) == "" && strings.TrimSpace(source.Name) == "" {
|
||||||
|
return fmt.Errorf("log_sources[%d]: service source requires service_unit or name", i)
|
||||||
|
}
|
||||||
|
case "file":
|
||||||
|
if strings.TrimSpace(source.FilePath) == "" && strings.TrimSpace(source.Name) == "" {
|
||||||
|
return fmt.Errorf("log_sources[%d]: file source requires file_path or name", i)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("log_sources[%d]: kind must be 'journal', 'service' or 'file'", i)
|
||||||
|
}
|
||||||
|
parser := strings.ToLower(strings.TrimSpace(source.Parser))
|
||||||
|
if parser == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch parser {
|
||||||
|
case "none", "syslog", "nginx_access", "json":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("log_sources[%d]: parser must be one of none|syslog|nginx_access|json", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func looksLikePlaceholder(value string) bool {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(value, "<") || strings.Contains(value, ">")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Config) ClientCertPath() string {
|
func (c *Config) ClientCertPath() string {
|
||||||
return c.StateDir + "/agent.crt"
|
return c.StateDir + "/agent.crt"
|
||||||
}
|
}
|
||||||
@@ -144,6 +218,28 @@ func (c *Config) LogSpoolDir() string {
|
|||||||
return c.StateDir + "/spool"
|
return c.StateDir + "/spool"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Config) LogOffsetsPath() string {
|
||||||
|
return c.StateDir + "/log.offsets.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) LogStatePath() string {
|
||||||
|
return c.StateDir + "/log.state.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) EnabledLogSources() []LogSource {
|
||||||
|
if len(c.LogSources) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]LogSource, 0, len(c.LogSources))
|
||||||
|
for _, source := range c.LogSources {
|
||||||
|
if source.Enabled != nil && !*source.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, source)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func dir(path string) string {
|
func dir(path string) string {
|
||||||
if idx := strings.LastIndex(path, string(os.PathSeparator)); idx != -1 {
|
if idx := strings.LastIndex(path, string(os.PathSeparator)); idx != -1 {
|
||||||
return path[:idx]
|
return path[:idx]
|
||||||
|
|||||||
+1010
-44
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,60 @@
|
|||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyNginxAccessParserSetsParsedTimestamp(t *testing.T) {
|
||||||
|
event := NewEvent(time.Date(2026, time.April, 15, 18, 43, 7, 0, time.UTC))
|
||||||
|
event.SourceKind = "file"
|
||||||
|
event.Message = `192.168.2.40 - - [28/Jan/2026:17:43:43 +0000] "GET / HTTP/1.1" 200 612 "-" "curl/8.0"`
|
||||||
|
|
||||||
|
if !applyNginxAccessParser(&event) {
|
||||||
|
t.Fatalf("expected nginx parser to match log line")
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := "2026-01-28T17:43:43Z"
|
||||||
|
if event.Timestamp != expected {
|
||||||
|
t.Fatalf("expected parsed timestamp %s, got %s", expected, event.Timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJSONTimestampSupportsEpochMillis(t *testing.T) {
|
||||||
|
event := NewEvent(time.Date(2026, time.April, 15, 18, 43, 7, 0, time.UTC))
|
||||||
|
event.SourceKind = "file"
|
||||||
|
event.Message = `{"timestamp":1700000000000,"message":"login event"}`
|
||||||
|
|
||||||
|
if !applyJSONParser(&event) {
|
||||||
|
t.Fatalf("expected json parser to parse payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := time.Unix(1700000000, 0).UTC().Format(time.RFC3339Nano)
|
||||||
|
if event.Timestamp != expected {
|
||||||
|
t.Fatalf("expected parsed timestamp %s, got %s", expected, event.Timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSyslogTimestampHandlesYearRollover(t *testing.T) {
|
||||||
|
now := time.Date(2026, time.January, 1, 0, 0, 30, 0, time.UTC)
|
||||||
|
parsed, ok := parseSyslogTimestamp("Dec 31 23:59:59", now)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected syslog timestamp to parse")
|
||||||
|
}
|
||||||
|
if parsed.Year() != 2025 {
|
||||||
|
t.Fatalf("expected year rollover to previous year, got %d", parsed.Year())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaybeSetParsedTimestampSkipsJournalAndServiceSources(t *testing.T) {
|
||||||
|
original := "2026-04-15T18:43:07Z"
|
||||||
|
event := Event{
|
||||||
|
Timestamp: original,
|
||||||
|
SourceKind: "service",
|
||||||
|
}
|
||||||
|
|
||||||
|
maybeSetParsedTimestamp(&event, time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
if event.Timestamp != original {
|
||||||
|
t.Fatalf("expected service timestamp to remain unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReadOffsets(path string) (map[string]int64, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string]int64{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]int64{}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteOffsets(path string, offsets map[string]int64) error {
|
||||||
|
if offsets == nil {
|
||||||
|
offsets = map[string]int64{}
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(offsets)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReadState(path string) (map[string]SourceState, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return map[string]SourceState{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]SourceState{}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteState(path string, state map[string]SourceState) error {
|
||||||
|
if state == nil {
|
||||||
|
state = map[string]SourceState{}
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(state)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
@@ -3,21 +3,42 @@ package logs
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
EventType string `json:"event_type"`
|
EventType string `json:"event_type"`
|
||||||
Unit string `json:"unit,omitempty"`
|
SourceKind string `json:"source_kind,omitempty"`
|
||||||
Priority string `json:"priority,omitempty"`
|
SourceName string `json:"source_name,omitempty"`
|
||||||
Hostname string `json:"hostname,omitempty"`
|
Unit string `json:"unit,omitempty"`
|
||||||
Username string `json:"username,omitempty"`
|
Priority string `json:"priority,omitempty"`
|
||||||
Principal string `json:"principal,omitempty"`
|
Hostname string `json:"hostname,omitempty"`
|
||||||
SourceIP string `json:"source_ip,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
Principal string `json:"principal,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
SourceIP string `json:"source_ip,omitempty"`
|
||||||
Raw string `json:"raw,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Fields map[string]string `json:"fields,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
|
Raw string `json:"raw,omitempty"`
|
||||||
|
Fields map[string]string `json:"fields,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEvent(ts time.Time) Event {
|
func NewEvent(ts time.Time) Event {
|
||||||
return Event{Timestamp: ts.UTC().Format(time.RFC3339Nano)}
|
return Event{Timestamp: ts.UTC().Format(time.RFC3339Nano)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SourceConfig struct {
|
||||||
|
SourceID string
|
||||||
|
Kind string
|
||||||
|
Name string
|
||||||
|
ServiceUnit string
|
||||||
|
FilePath string
|
||||||
|
Parser string
|
||||||
|
IncludeMatches map[string][]string
|
||||||
|
ExcludeMatches map[string][]string
|
||||||
|
Category string
|
||||||
|
EventType string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceState struct {
|
||||||
|
Cursor string `json:"cursor,omitempty"`
|
||||||
|
Offset int64 `json:"offset,omitempty"`
|
||||||
|
Inode uint64 `json:"inode,omitempty"`
|
||||||
|
}
|
||||||
Binary file not shown.
Executable
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
|
||||||
|
echo "Run as root (or via sudo)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
AGENT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
BIN_SOURCE="${1:-${AGENT_DIR}/keywarden-agent}"
|
||||||
|
UNIT_SOURCE="${AGENT_DIR}/systemd/keywarden-agent.service"
|
||||||
|
ENV_SOURCE="${AGENT_DIR}/systemd/keywarden-agent.env.example"
|
||||||
|
|
||||||
|
if [[ ! -f "${BIN_SOURCE}" ]]; then
|
||||||
|
echo "Agent binary not found: ${BIN_SOURCE}" >&2
|
||||||
|
echo "Build it first: go build -o keywarden-agent ./cmd/keywarden-agent" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${UNIT_SOURCE}" ]]; then
|
||||||
|
echo "Unit file not found: ${UNIT_SOURCE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -D -m 0755 "${BIN_SOURCE}" /usr/local/bin/keywarden-agent
|
||||||
|
install -D -m 0644 "${UNIT_SOURCE}" /etc/systemd/system/keywarden-agent.service
|
||||||
|
|
||||||
|
if [[ ! -f /etc/default/keywarden-agent ]]; then
|
||||||
|
install -D -m 0640 "${ENV_SOURCE}" /etc/default/keywarden-agent
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p /etc/keywarden
|
||||||
|
if [[ ! -f /etc/keywarden/agent.json ]]; then
|
||||||
|
cat > /etc/keywarden/agent.json <<'EOF'
|
||||||
|
{
|
||||||
|
"server_url": "https://keywarden.example.com/api/v1"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
chmod 0600 /etc/keywarden/agent.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
cat <<'EOF'
|
||||||
|
Installed:
|
||||||
|
- /usr/local/bin/keywarden-agent
|
||||||
|
- /etc/systemd/system/keywarden-agent.service
|
||||||
|
- /etc/default/keywarden-agent (if it did not already exist)
|
||||||
|
- /etc/keywarden/agent.json (bootstrap file, if it did not already exist)
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1) Edit /etc/default/keywarden-agent and set KEYWARDEN_SERVER_URL + KEYWARDEN_ENROLL_TOKEN.
|
||||||
|
2) (Optional) Edit /etc/keywarden/agent.json with additional settings.
|
||||||
|
3) Enable and start:
|
||||||
|
systemctl enable --now keywarden-agent
|
||||||
|
4) Check status/logs:
|
||||||
|
systemctl status keywarden-agent
|
||||||
|
journalctl -u keywarden-agent -f
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Keywarden server URL (required on first boot if /etc/keywarden/agent.json does not exist)
|
||||||
|
KEYWARDEN_SERVER_URL=https://keywarden.example.com/api/v1
|
||||||
|
|
||||||
|
# Enrollment token used only for first enrollment / forced re-enrollment.
|
||||||
|
# Remove after successful enrollment.
|
||||||
|
KEYWARDEN_ENROLL_TOKEN=
|
||||||
|
|
||||||
|
# Optional path to a private CA PEM for verifying the Keywarden server TLS cert.
|
||||||
|
# KEYWARDEN_SERVER_CA_PATH=/etc/ssl/certs/keywarden-ca.pem
|
||||||
|
|
||||||
|
# Optional override for the persisted per-agent token in agent.json.
|
||||||
|
# Normally leave unset.
|
||||||
|
# KEYWARDEN_AGENT_API_TOKEN=
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Keywarden Agent
|
||||||
|
Documentation=https://git.ntbx.io/boris/keywarden
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
EnvironmentFile=-/etc/default/keywarden-agent
|
||||||
|
EnvironmentFile=-/etc/sysconfig/keywarden-agent
|
||||||
|
ExecStart=/usr/local/bin/keywarden-agent -config /etc/keywarden/agent.json
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -25,6 +25,8 @@ class AccessRequestAdmin(GuardedModelAdmin):
|
|||||||
"request_shell",
|
"request_shell",
|
||||||
"request_logs",
|
"request_logs",
|
||||||
"request_users",
|
"request_users",
|
||||||
|
"requested_duration_hours",
|
||||||
|
"requested_server_username",
|
||||||
"requested_at",
|
"requested_at",
|
||||||
"expires_at",
|
"expires_at",
|
||||||
"decided_by",
|
"decided_by",
|
||||||
@@ -56,6 +58,8 @@ class AccessRequestAdmin(GuardedModelAdmin):
|
|||||||
"request_shell",
|
"request_shell",
|
||||||
"request_logs",
|
"request_logs",
|
||||||
"request_users",
|
"request_users",
|
||||||
|
"requested_duration_hours",
|
||||||
|
"requested_server_username",
|
||||||
"expires_at",
|
"expires_at",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -73,6 +77,8 @@ class AccessRequestAdmin(GuardedModelAdmin):
|
|||||||
"request_shell",
|
"request_shell",
|
||||||
"request_logs",
|
"request_logs",
|
||||||
"request_users",
|
"request_users",
|
||||||
|
"requested_duration_hours",
|
||||||
|
"requested_server_username",
|
||||||
"expires_at",
|
"expires_at",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("access", "0003_access_request_options"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="accessrequest",
|
||||||
|
name="requested_duration_hours",
|
||||||
|
field=models.PositiveIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="accessrequest",
|
||||||
|
name="requested_server_username",
|
||||||
|
field=models.CharField(blank=True, max_length=128),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -31,6 +31,8 @@ class AccessRequest(models.Model):
|
|||||||
request_shell = models.BooleanField(default=False)
|
request_shell = models.BooleanField(default=False)
|
||||||
request_logs = models.BooleanField(default=False)
|
request_logs = models.BooleanField(default=False)
|
||||||
request_users = models.BooleanField(default=False)
|
request_users = models.BooleanField(default=False)
|
||||||
|
requested_duration_hours = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
requested_server_username = models.CharField(max_length=128, blank=True)
|
||||||
requested_at = models.DateTimeField(default=timezone.now, editable=False)
|
requested_at = models.DateTimeField(default=timezone.now, editable=False)
|
||||||
decided_at = models.DateTimeField(null=True, blank=True)
|
decided_at = models.DateTimeField(null=True, blank=True)
|
||||||
expires_at = models.DateTimeField(null=True, blank=True)
|
expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ def sync_server_view_perm(access_request: AccessRequest) -> None:
|
|||||||
server_id=access_request.server_id,
|
server_id=access_request.server_id,
|
||||||
status=AccessRequest.Status.APPROVED,
|
status=AccessRequest.Status.APPROVED,
|
||||||
)
|
)
|
||||||
|
.filter(Q(request_shell=True) | Q(request_logs=True) | Q(request_users=True))
|
||||||
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
|
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
|
||||||
.exists()
|
.exists()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from django.contrib import admin
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from unfold.admin import ModelAdmin
|
from unfold.admin import ModelAdmin
|
||||||
|
|
||||||
from .models import ErasureRequest, ExternalIdentity
|
from .models import ErasureRequest, ExternalIdentity, NativeAccountSecurity
|
||||||
|
|
||||||
|
|
||||||
class ErasureRequestAdminForm(forms.ModelForm):
|
class ErasureRequestAdminForm(forms.ModelForm):
|
||||||
@@ -89,3 +89,20 @@ class ExternalIdentityAdmin(ModelAdmin):
|
|||||||
|
|
||||||
def has_delete_permission(self, request, obj=None) -> bool:
|
def has_delete_permission(self, request, obj=None) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(NativeAccountSecurity)
|
||||||
|
class NativeAccountSecurityAdmin(ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"user",
|
||||||
|
"email_verified",
|
||||||
|
"email_verified_at",
|
||||||
|
"verification_email_sent_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
list_filter = ("email_verified",)
|
||||||
|
search_fields = ("user__username", "user__email")
|
||||||
|
readonly_fields = ("user", "email_verified_at", "verification_email_sent_at", "updated_at")
|
||||||
|
|
||||||
|
def has_add_permission(self, request) -> bool:
|
||||||
|
return False
|
||||||
@@ -1,4 +1,50 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm
|
||||||
|
|
||||||
|
from .models import ExternalIdentity
|
||||||
|
|
||||||
|
|
||||||
|
class NativePasswordResetForm(PasswordResetForm):
|
||||||
|
"""Password reset form that excludes SSO-linked accounts."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["email"].widget.attrs.update(
|
||||||
|
{
|
||||||
|
"class": "block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500",
|
||||||
|
"placeholder": "you@example.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_users(self, email):
|
||||||
|
UserModel = get_user_model()
|
||||||
|
email_field_name = UserModel.get_email_field_name()
|
||||||
|
users = UserModel._default_manager.filter(
|
||||||
|
**{
|
||||||
|
f"{email_field_name}__iexact": email,
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for user in users:
|
||||||
|
email_value = getattr(user, email_field_name, "") or ""
|
||||||
|
if not user.has_usable_password():
|
||||||
|
continue
|
||||||
|
if ExternalIdentity.objects.filter(user=user).exists():
|
||||||
|
continue
|
||||||
|
if email.lower() != email_value.lower():
|
||||||
|
continue
|
||||||
|
yield user
|
||||||
|
|
||||||
|
|
||||||
|
class StyledSetPasswordForm(SetPasswordForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
field_attrs = {
|
||||||
|
"class": "block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500",
|
||||||
|
}
|
||||||
|
self.fields["new_password1"].widget.attrs.update(field_attrs)
|
||||||
|
self.fields["new_password2"].widget.attrs.update(field_attrs)
|
||||||
|
|
||||||
|
|
||||||
class ErasureRequestForm(forms.Form):
|
class ErasureRequestForm(forms.Form):
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0007_externalidentity"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="NativeAccountSecurity",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("email_verified", models.BooleanField(db_index=True, default=False)),
|
||||||
|
("email_verified_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("verification_email_sent_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="native_account_security",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Native account security",
|
||||||
|
"verbose_name_plural": "Native account security",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -54,6 +54,25 @@ class ExternalIdentity(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NativeAccountSecurity(models.Model):
|
||||||
|
user = models.OneToOneField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="native_account_security",
|
||||||
|
)
|
||||||
|
email_verified = models.BooleanField(default=False, db_index=True)
|
||||||
|
email_verified_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
verification_email_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Native account security"
|
||||||
|
verbose_name_plural = "Native account security"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"native-security:{self.user_id}:{'verified' if self.email_verified else 'unverified'}"
|
||||||
|
|
||||||
|
|
||||||
class ErasureRequest(models.Model):
|
class ErasureRequest(models.Model):
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
PENDING = "pending", "Pending"
|
PENDING = "pending", "Pending"
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
from allauth.exceptions import ImmediateHttpResponse
|
from allauth.exceptions import ImmediateHttpResponse
|
||||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||||
|
|
||||||
from apps.core.rbac import ROLE_USER, get_user_role, set_user_role
|
|
||||||
|
|
||||||
from .identity import (
|
from .identity import (
|
||||||
build_unique_username,
|
build_unique_username,
|
||||||
normalize_email,
|
normalize_email,
|
||||||
@@ -23,8 +21,11 @@ from .models import ExternalIdentity
|
|||||||
|
|
||||||
class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
|
class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||||
def pre_social_login(self, request, sociallogin):
|
def pre_social_login(self, request, sociallogin):
|
||||||
|
provider = str(sociallogin.account.provider or "").strip().lower()
|
||||||
|
if provider != "github":
|
||||||
|
self._reject(request, "Only GitHub social authentication is enabled.")
|
||||||
|
|
||||||
email = self._extract_email(sociallogin)
|
email = self._extract_email(sociallogin)
|
||||||
provider = sociallogin.account.provider
|
|
||||||
subject = str(sociallogin.account.uid or "").strip()
|
subject = str(sociallogin.account.uid or "").strip()
|
||||||
if not subject:
|
if not subject:
|
||||||
self._reject(request, "Login failed: provider did not return a stable user id.")
|
self._reject(request, "Login failed: provider did not return a stable user id.")
|
||||||
@@ -35,10 +36,13 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
|
|||||||
if not self._email_verified(sociallogin, email):
|
if not self._email_verified(sociallogin, email):
|
||||||
self._reject(request, "Login failed: provider email is not verified.")
|
self._reject(request, "Login failed: provider email is not verified.")
|
||||||
|
|
||||||
if sociallogin.is_existing:
|
if self._is_connect_flow(request, sociallogin):
|
||||||
if normalize_email(sociallogin.user.email) != email:
|
self._handle_connect_flow(
|
||||||
raise PermissionDenied("Social login email does not match enrolled account email.")
|
request,
|
||||||
self._sync_identity(sociallogin.user, sociallogin)
|
sociallogin,
|
||||||
|
provider=provider,
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
existing_link = (
|
existing_link = (
|
||||||
@@ -50,17 +54,19 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
|
|||||||
)
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if existing_link:
|
if not existing_link:
|
||||||
if normalize_email(existing_link.user.email) != email:
|
self._reject(
|
||||||
raise PermissionDenied("Linked social account email mismatch.")
|
request,
|
||||||
sociallogin.connect(request, existing_link.user)
|
"GitHub is not linked for this account. Sign in with your existing method and link GitHub from Profile.",
|
||||||
self._sync_identity(existing_link.user, sociallogin)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
user = get_user_model().objects.filter(email__iexact=email).first()
|
if sociallogin.is_existing:
|
||||||
if user:
|
if getattr(sociallogin.user, "pk", None) != existing_link.user_id:
|
||||||
sociallogin.connect(request, user)
|
raise PermissionDenied("GitHub identity is already linked to another Keywarden account.")
|
||||||
self._sync_identity(user, sociallogin)
|
else:
|
||||||
|
sociallogin.connect(request, existing_link.user)
|
||||||
|
|
||||||
|
self._sync_identity(existing_link.user, sociallogin)
|
||||||
|
|
||||||
def populate_user(self, request, sociallogin, data):
|
def populate_user(self, request, sociallogin, data):
|
||||||
user = super().populate_user(request, sociallogin, data)
|
user = super().populate_user(request, sociallogin, data)
|
||||||
@@ -78,15 +84,7 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
def save_user(self, request, sociallogin, form=None):
|
def save_user(self, request, sociallogin, form=None):
|
||||||
user = super().save_user(request, sociallogin, form=form)
|
raise PermissionDenied("Social auto-onboarding is disabled. Link GitHub from your profile.")
|
||||||
email = self._extract_email(sociallogin)
|
|
||||||
if normalize_email(user.email) != email:
|
|
||||||
raise PermissionDenied("Social login email does not match enrolled account email.")
|
|
||||||
if get_user_role(user, default=None) is None:
|
|
||||||
set_user_role(user, ROLE_USER)
|
|
||||||
user.save(update_fields=["is_staff", "is_superuser"])
|
|
||||||
self._sync_identity(user, sociallogin)
|
|
||||||
return user
|
|
||||||
|
|
||||||
def _sync_identity(self, user, sociallogin) -> None:
|
def _sync_identity(self, user, sociallogin) -> None:
|
||||||
extra_data = dict(sociallogin.account.extra_data or {})
|
extra_data = dict(sociallogin.account.extra_data or {})
|
||||||
@@ -109,11 +107,67 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
|
|||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
raise PermissionDenied(str(exc)) from exc
|
raise PermissionDenied(str(exc)) from exc
|
||||||
|
|
||||||
|
def _handle_connect_flow(self, request, sociallogin, *, provider: str, subject: str) -> None:
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
self._reject(
|
||||||
|
request,
|
||||||
|
"Sign in with Keywarden first, then link GitHub from your profile.",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
existing_subject_link = (
|
||||||
|
ExternalIdentity.objects.select_related("user")
|
||||||
|
.filter(
|
||||||
|
provider_type=ExternalIdentity.ProviderType.SOCIAL,
|
||||||
|
provider_id=provider,
|
||||||
|
subject=subject,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing_subject_link and existing_subject_link.user_id != user.id:
|
||||||
|
raise PermissionDenied("This GitHub account is already linked to another user.")
|
||||||
|
|
||||||
|
existing_provider_link = (
|
||||||
|
ExternalIdentity.objects.filter(
|
||||||
|
user=user,
|
||||||
|
provider_type=ExternalIdentity.ProviderType.SOCIAL,
|
||||||
|
provider_id=provider,
|
||||||
|
)
|
||||||
|
.exclude(subject=subject)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing_provider_link:
|
||||||
|
self._reject(
|
||||||
|
request,
|
||||||
|
"A different GitHub account is already linked. Unlink it before linking another account.",
|
||||||
|
redirect_to="accounts:profile",
|
||||||
|
)
|
||||||
|
|
||||||
|
if sociallogin.is_existing and getattr(sociallogin.user, "pk", None) != user.pk:
|
||||||
|
raise PermissionDenied("This GitHub account is already linked to another user.")
|
||||||
|
|
||||||
|
if not sociallogin.is_existing:
|
||||||
|
sociallogin.connect(request, user)
|
||||||
|
self._sync_identity(user, sociallogin)
|
||||||
|
|
||||||
|
def _is_connect_flow(self, request, sociallogin) -> bool:
|
||||||
|
process = str(
|
||||||
|
(getattr(sociallogin, "state", {}) or {}).get("process")
|
||||||
|
or request.GET.get("process")
|
||||||
|
or ""
|
||||||
|
).strip().lower()
|
||||||
|
return process == "connect"
|
||||||
|
|
||||||
def _extract_email(self, sociallogin) -> str:
|
def _extract_email(self, sociallogin) -> str:
|
||||||
user_email = normalize_email(getattr(sociallogin.user, "email", ""))
|
provider_email = normalize_email((sociallogin.account.extra_data or {}).get("email"))
|
||||||
if user_email:
|
if provider_email:
|
||||||
return user_email
|
return provider_email
|
||||||
return normalize_email((sociallogin.account.extra_data or {}).get("email"))
|
for address in getattr(sociallogin, "email_addresses", []):
|
||||||
|
candidate = normalize_email(getattr(address, "email", ""))
|
||||||
|
if candidate:
|
||||||
|
return candidate
|
||||||
|
return normalize_email(getattr(sociallogin.user, "email", ""))
|
||||||
|
|
||||||
def _email_verified(self, sociallogin, email: str) -> bool:
|
def _email_verified(self, sociallogin, email: str) -> bool:
|
||||||
normalized = normalize_email(email)
|
normalized = normalize_email(email)
|
||||||
@@ -130,6 +184,9 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _reject(self, request, reason: str) -> None:
|
def _reject(self, request, reason: str, redirect_to: str = "accounts:login") -> None:
|
||||||
messages.error(request, reason)
|
messages.error(request, reason)
|
||||||
raise ImmediateHttpResponse(redirect("accounts:login"))
|
raise ImmediateHttpResponse(redirect(redirect_to))
|
||||||
|
|
||||||
|
def get_connect_redirect_url(self, request, socialaccount):
|
||||||
|
return reverse("accounts:profile")
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Hello {{ user.get_username }},
|
||||||
|
|
||||||
|
Verify your Keywarden email by opening this link:
|
||||||
|
{{ verification_link }}
|
||||||
|
|
||||||
|
This verification link expires in {{ expires_seconds }} seconds.
|
||||||
|
|
||||||
|
If you did not request this verification email, you can ignore it.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Verify your Keywarden email
|
||||||
@@ -82,6 +82,16 @@
|
|||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</button>
|
</button>
|
||||||
|
{% if password_reset_enabled %}
|
||||||
|
<div class="text-center">
|
||||||
|
<a
|
||||||
|
href="{% url 'accounts:password_reset' %}"
|
||||||
|
class="text-sm font-medium text-blue-700 hover:underline"
|
||||||
|
>
|
||||||
|
Forgot your password?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if oidc_enabled or social_providers %}
|
{% if oidc_enabled or social_providers %}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Password Updated • Keywarden{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-md rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Password updated</h1>
|
||||||
|
<p class="mt-3 text-sm text-gray-600">
|
||||||
|
Your Keywarden password has been changed successfully.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="{% url 'accounts:login' %}?native=1"
|
||||||
|
class="mt-6 inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Set New Password • Keywarden{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-md rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Set a new password</h1>
|
||||||
|
{% if validlink %}
|
||||||
|
<form method="post" class="mt-6 space-y-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-900" for="{{ form.new_password1.id_for_label }}">New password</label>
|
||||||
|
{{ form.new_password1 }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-900" for="{{ form.new_password2.id_for_label }}">Confirm password</label>
|
||||||
|
{{ form.new_password2 }}
|
||||||
|
</div>
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="rounded-lg bg-red-50 p-3 text-sm text-red-800">
|
||||||
|
Please fix the errors and try again.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
|
||||||
|
>
|
||||||
|
Update password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-4 text-sm text-gray-600">
|
||||||
|
This password reset link is invalid or expired.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="{% url 'accounts:password_reset' %}"
|
||||||
|
class="mt-6 inline-flex w-full items-center justify-center rounded-lg border border-gray-200 bg-gray-50 px-5 py-2.5 text-sm font-semibold text-gray-800 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Request a new reset link
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Reset Email Sent • Keywarden{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-md rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Check your email</h1>
|
||||||
|
<p class="mt-3 text-sm text-gray-600">
|
||||||
|
If a native Keywarden account exists for the submitted email, a reset link has been sent.
|
||||||
|
</p>
|
||||||
|
<p class="mt-3 text-sm text-gray-600">
|
||||||
|
SSO accounts must reset credentials in the identity provider.
|
||||||
|
{% if idp_account_portal_url %}
|
||||||
|
<a href="{{ idp_account_portal_url }}" class="font-semibold text-blue-700 hover:underline">Open IdP portal</a>.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="{% url 'accounts:login' %}?native=1"
|
||||||
|
class="mt-6 inline-flex w-full items-center justify-center rounded-lg border border-gray-200 bg-gray-50 px-5 py-2.5 text-sm font-semibold text-gray-800 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Return to login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
Hello {{ user.get_username }},
|
||||||
|
|
||||||
|
You requested a password reset for your Keywarden account.
|
||||||
|
|
||||||
|
Reset link:
|
||||||
|
{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %}
|
||||||
|
|
||||||
|
If you did not request this, you can ignore this email.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Reset Password • Keywarden{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-md rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-8">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Reset your password</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
|
Enter your Keywarden account email to receive a password reset link.
|
||||||
|
</p>
|
||||||
|
<form method="post" class="mt-6 space-y-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-sm font-medium text-gray-900" for="{{ form.email.id_for_label }}">Email</label>
|
||||||
|
{{ form.email }}
|
||||||
|
</div>
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="rounded-lg bg-red-50 p-3 text-sm text-red-800">
|
||||||
|
Please correct the highlighted issues and retry.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
|
||||||
|
>
|
||||||
|
Send reset link
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="mt-6 text-xs text-gray-500">
|
||||||
|
Accounts managed by SSO do not use local password reset.
|
||||||
|
{% if idp_account_portal_url %}
|
||||||
|
Use your identity provider portal:
|
||||||
|
<a href="{{ idp_account_portal_url }}" class="font-semibold text-blue-700 hover:underline">{{ idp_account_portal_url }}</a>.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Keywarden password reset
|
||||||
@@ -3,6 +3,16 @@
|
|||||||
{% block title %}Profile • Keywarden{% endblock %}
|
{% block title %}Profile • Keywarden{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="mb-4 space-y-2">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="rounded-lg border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(340px,1fr)] lg:items-start">
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(340px,1fr)] lg:items-start">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
@@ -205,6 +215,100 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="space-y-6 lg:sticky lg:top-6">
|
<aside class="space-y-6 lg:sticky lg:top-6">
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h2 class="text-base font-semibold text-gray-900">Interface display mode</h2>
|
||||||
|
<p class="text-sm text-gray-500">Choose how wide workspace pages render.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 inline-flex rounded-lg border border-gray-200 bg-gray-50 p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="js-display-mode-btn rounded-md px-3 py-1.5 text-xs font-semibold text-gray-600 transition"
|
||||||
|
data-display-mode="compact"
|
||||||
|
>
|
||||||
|
Compact
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="js-display-mode-btn rounded-md px-3 py-1.5 text-xs font-semibold text-gray-600 transition"
|
||||||
|
data-display-mode="full"
|
||||||
|
>
|
||||||
|
Full
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-xs text-gray-500">
|
||||||
|
Compact keeps the current constrained view. Full expands to near full-width for denser audit/log tables.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h2 class="text-base font-semibold text-gray-900">Native account security</h2>
|
||||||
|
<p class="text-sm text-gray-500">Password reset and email verification for Keywarden-native credentials.</p>
|
||||||
|
</div>
|
||||||
|
{% if native_security_enabled %}
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="text-sm font-semibold text-gray-900">Email verification</span>
|
||||||
|
{% if native_account_security and native_account_security.email_verified %}
|
||||||
|
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-700">Verified</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">Unverified</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if email_verification_enabled %}
|
||||||
|
{% if native_account_security and native_account_security.email_verified %}
|
||||||
|
<p class="mt-2 text-xs text-gray-600">
|
||||||
|
Verified at {{ native_account_security.email_verified_at|date:"M j, Y H:i" }}.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-2 text-xs text-gray-600">
|
||||||
|
Send a verification link to {{ user.email }}.
|
||||||
|
</p>
|
||||||
|
<form method="post" class="mt-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="form_type" value="send_email_verification">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
>
|
||||||
|
Send verification email
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-2 text-xs text-gray-600">Email verification is disabled in this environment.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
|
<p class="text-sm font-semibold text-gray-900">Password reset</p>
|
||||||
|
<p class="mt-2 text-xs text-gray-600">
|
||||||
|
Reset your Keywarden-native password via secure email token flow.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="{% url 'accounts:password_reset' %}"
|
||||||
|
class="mt-3 inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 hover:bg-blue-100"
|
||||||
|
>
|
||||||
|
Start password reset
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
|
<p class="text-sm text-gray-700">
|
||||||
|
Native password reset and local email verification are disabled because this account uses SSO.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-xs text-gray-600">
|
||||||
|
Manage password and email verification in your identity provider.
|
||||||
|
{% if idp_account_portal_url %}
|
||||||
|
<a href="{{ idp_account_portal_url }}" class="font-semibold text-blue-700 hover:underline">Open IdP account portal</a>.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2>
|
<h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2>
|
||||||
@@ -283,53 +387,68 @@
|
|||||||
|
|
||||||
<article class="overflow-hidden rounded-xl border border-gray-200">
|
<article class="overflow-hidden rounded-xl border border-gray-200">
|
||||||
<div class="flex items-center justify-between bg-gray-50 px-4 py-3">
|
<div class="flex items-center justify-between bg-gray-50 px-4 py-3">
|
||||||
<h3 class="text-sm font-semibold text-gray-900">Social Providers</h3>
|
<h3 class="text-sm font-semibold text-gray-900">GitHub</h3>
|
||||||
<span class="text-xs text-gray-500">GitHub, Google, Apple and more</span>
|
{% if github_identity %}
|
||||||
|
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-700">Linked</span>
|
||||||
|
{% elif github_social_configured %}
|
||||||
|
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">Ready to link</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center rounded-full bg-gray-200 px-2 py-0.5 text-[11px] font-semibold text-gray-700">Unavailable</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="space-y-3 p-4">
|
||||||
{% for provider in social_provider_cards %}
|
{% if github_identity %}
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
<dl class="grid grid-cols-1 gap-2 text-xs text-gray-600">
|
||||||
<div class="min-w-0">
|
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
|
||||||
<p class="text-sm font-semibold text-gray-900">{{ provider.name }}</p>
|
<dt class="font-semibold text-gray-700">Linked email</dt>
|
||||||
{% if provider.linked and provider.identity %}
|
<dd>{{ github_identity.email_at_link }}</dd>
|
||||||
<p class="text-xs text-gray-600">
|
|
||||||
Linked as {{ provider.identity.email_at_link }}
|
|
||||||
{% if provider.identity.last_login_at %}• last login {{ provider.identity.last_login_at|date:"M j, Y H:i" }}{% endif %}
|
|
||||||
</p>
|
|
||||||
{% elif provider.configured %}
|
|
||||||
<p class="text-xs text-gray-600">Configured and ready to link.</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-xs text-gray-500">Placeholder. Configure provider credentials to enable linking.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
|
||||||
{% if provider.linked %}
|
<dt class="font-semibold text-gray-700">Subject</dt>
|
||||||
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-700">Linked</span>
|
<dd class="break-all font-mono">{{ github_identity.subject }}</dd>
|
||||||
{% elif provider.configured %}
|
|
||||||
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">Ready</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-200 px-2 py-0.5 text-[11px] font-semibold text-gray-700">Placeholder</span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if provider.configured %}
|
|
||||||
<a
|
|
||||||
href="{{ provider.login_url }}"
|
|
||||||
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
|
|
||||||
>
|
|
||||||
{% if provider.linked %}Re-link{% else %}Link{% endif %}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled
|
|
||||||
class="inline-flex cursor-not-allowed items-center rounded-lg border border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-500"
|
|
||||||
>
|
|
||||||
Configure
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% if github_identity.last_login_at %}
|
||||||
|
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
|
||||||
|
<dt class="font-semibold text-gray-700">Last login</dt>
|
||||||
|
<dd>{{ github_identity.last_login_at|date:"M j, Y H:i" }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
{% if github_social_connect_url %}
|
||||||
|
<a
|
||||||
|
href="{{ github_social_connect_url }}"
|
||||||
|
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
>
|
||||||
|
Re-link GitHub
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" class="inline-flex">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="form_type" value="unlink_github">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 focus:outline-none focus:ring-2 focus:ring-rose-300"
|
||||||
|
>
|
||||||
|
Unlink GitHub
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% elif github_social_configured %}
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Link GitHub while signed in. GitHub login is only available after linking to an existing Keywarden account.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="{{ github_social_connect_url }}"
|
||||||
|
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
>
|
||||||
|
Link GitHub
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
GitHub authentication is not configured in this environment.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,11 +458,9 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
function getCookie(name) {
|
function getCsrfToken() {
|
||||||
var value = "; " + document.cookie;
|
if (typeof window.keywardenGetCsrfToken === "function") {
|
||||||
var parts = value.split("; " + name + "=");
|
return window.keywardenGetCsrfToken();
|
||||||
if (parts.length === 2) {
|
|
||||||
return parts.pop().split(";").shift();
|
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -366,7 +483,7 @@
|
|||||||
if (!window.confirm("Regenerate the certificate for this key?")) {
|
if (!window.confirm("Regenerate the certificate for this key?")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var csrf = getCookie("csrftoken");
|
var csrf = getCsrfToken();
|
||||||
fetch("/api/v1/keys/" + keyId + "/certificate", {
|
fetch("/api/v1/keys/" + keyId + "/certificate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
@@ -385,6 +502,31 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDisplayModeButtonState(button, isActive) {
|
||||||
|
if (isActive) {
|
||||||
|
button.classList.remove("text-gray-600");
|
||||||
|
button.classList.add("bg-blue-700", "text-white", "shadow-sm");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.classList.remove("bg-blue-700", "text-white", "shadow-sm");
|
||||||
|
button.classList.add("text-gray-600");
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentDisplayMode() {
|
||||||
|
if (typeof window.keywardenGetDisplayMode === "function") {
|
||||||
|
return window.keywardenGetDisplayMode();
|
||||||
|
}
|
||||||
|
return document.documentElement.getAttribute("data-display-mode") === "full" ? "full" : "compact";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDisplayModeUi(mode) {
|
||||||
|
var buttons = document.querySelectorAll(".js-display-mode-btn");
|
||||||
|
for (var i = 0; i < buttons.length; i += 1) {
|
||||||
|
var button = buttons[i];
|
||||||
|
setDisplayModeButtonState(button, button.getAttribute("data-display-mode") === mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var downloadButtons = document.querySelectorAll("[data-download-url]");
|
var downloadButtons = document.querySelectorAll("[data-download-url]");
|
||||||
for (var i = 0; i < downloadButtons.length; i += 1) {
|
for (var i = 0; i < downloadButtons.length; i += 1) {
|
||||||
downloadButtons[i].addEventListener("click", handleDownload);
|
downloadButtons[i].addEventListener("click", handleDownload);
|
||||||
@@ -393,6 +535,32 @@
|
|||||||
for (var j = 0; j < buttons.length; j += 1) {
|
for (var j = 0; j < buttons.length; j += 1) {
|
||||||
buttons[j].addEventListener("click", handleRegenerate);
|
buttons[j].addEventListener("click", handleRegenerate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var displayModeButtons = document.querySelectorAll(".js-display-mode-btn");
|
||||||
|
for (var k = 0; k < displayModeButtons.length; k += 1) {
|
||||||
|
displayModeButtons[k].addEventListener("click", function (event) {
|
||||||
|
var target = event.currentTarget;
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var mode = target.getAttribute("data-display-mode") || "compact";
|
||||||
|
if (typeof window.keywardenSetDisplayMode === "function") {
|
||||||
|
window.keywardenSetDisplayMode(mode);
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute("data-display-mode", mode === "full" ? "full" : "compact");
|
||||||
|
}
|
||||||
|
applyDisplayModeUi(currentDisplayMode());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keywarden:display-mode", function (event) {
|
||||||
|
var mode = currentDisplayMode();
|
||||||
|
if (event && event.detail && event.detail.mode) {
|
||||||
|
mode = event.detail.mode === "full" ? "full" : "compact";
|
||||||
|
}
|
||||||
|
applyDisplayModeUi(mode);
|
||||||
|
});
|
||||||
|
applyDisplayModeUi(currentDisplayMode());
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,11 +1,30 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from allauth.exceptions import ImmediateHttpResponse
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.contrib.messages.storage.fallback import FallbackStorage
|
||||||
|
from django.contrib.sessions.middleware import SessionMiddleware
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.test import TestCase
|
from django.core import signing
|
||||||
|
from django.test import RequestFactory, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
from apps.accounts.identity import build_unique_username, normalize_email
|
from apps.accounts.forms import NativePasswordResetForm
|
||||||
from apps.accounts.models import ExternalIdentity
|
from apps.accounts.identity import (
|
||||||
|
apply_optional_role_mapping,
|
||||||
|
build_unique_username,
|
||||||
|
claim_bool,
|
||||||
|
claim_text,
|
||||||
|
normalize_email,
|
||||||
|
parse_groups,
|
||||||
|
upsert_external_identity,
|
||||||
|
)
|
||||||
|
from apps.accounts.models import ExternalIdentity, NativeAccountSecurity
|
||||||
|
from apps.core.rbac import ROLE_ADMIN, ROLE_USER, get_user_role, set_user_role
|
||||||
|
from apps.accounts.social_auth import KeywardenSocialAccountAdapter
|
||||||
|
|
||||||
|
|
||||||
class IdentityHelpersTests(TestCase):
|
class IdentityHelpersTests(TestCase):
|
||||||
@@ -19,6 +38,20 @@ class IdentityHelpersTests(TestCase):
|
|||||||
self.assertNotEqual(generated, "alice")
|
self.assertNotEqual(generated, "alice")
|
||||||
self.assertTrue(generated.startswith("alice"))
|
self.assertTrue(generated.startswith("alice"))
|
||||||
|
|
||||||
|
def test_parse_groups_supports_multiple_input_shapes(self):
|
||||||
|
self.assertEqual(parse_groups(None), set())
|
||||||
|
self.assertEqual(parse_groups("Admins, Dev Ops"), {"admins", "dev", "ops"})
|
||||||
|
self.assertEqual(parse_groups(["Team-A", " team-b "]), {"team-a", "team-b"})
|
||||||
|
self.assertEqual(parse_groups(123), {"123"})
|
||||||
|
|
||||||
|
def test_claim_helpers(self):
|
||||||
|
claims = {"name": " Alice ", "enabled": "YES", "flag": False}
|
||||||
|
self.assertEqual(claim_text(claims, "name"), "Alice")
|
||||||
|
self.assertEqual(claim_text(claims, "missing", "fallback"), "fallback")
|
||||||
|
self.assertTrue(claim_bool(claims, "enabled"))
|
||||||
|
self.assertFalse(claim_bool(claims, "flag", default=True))
|
||||||
|
self.assertTrue(claim_bool(claims, "missing", default=True))
|
||||||
|
|
||||||
|
|
||||||
class ImmutableEmailTests(TestCase):
|
class ImmutableEmailTests(TestCase):
|
||||||
def test_email_change_is_blocked(self):
|
def test_email_change_is_blocked(self):
|
||||||
@@ -42,3 +75,335 @@ class ExternalIdentityTests(TestCase):
|
|||||||
email_at_link="charlie@example.com",
|
email_at_link="charlie@example.com",
|
||||||
)
|
)
|
||||||
self.assertIn("corporate-sso", str(identity))
|
self.assertIn("corporate-sso", str(identity))
|
||||||
|
|
||||||
|
def test_upsert_external_identity_create_and_update(self):
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.create_user(username="oidc1", email="oidc1@example.com", password="pass12345")
|
||||||
|
identity = upsert_external_identity(
|
||||||
|
user=user,
|
||||||
|
provider_type=ExternalIdentity.ProviderType.OIDC,
|
||||||
|
provider_id="oidc",
|
||||||
|
subject="sub-1",
|
||||||
|
email="oidc1@example.com",
|
||||||
|
issuer="https://issuer.example.com",
|
||||||
|
details={"groups": ["admins"]},
|
||||||
|
)
|
||||||
|
self.assertEqual(identity.user_id, user.id)
|
||||||
|
self.assertEqual(identity.issuer, "https://issuer.example.com")
|
||||||
|
self.assertEqual(identity.email_at_link, "oidc1@example.com")
|
||||||
|
|
||||||
|
refreshed = upsert_external_identity(
|
||||||
|
user=user,
|
||||||
|
provider_type=ExternalIdentity.ProviderType.OIDC,
|
||||||
|
provider_id="oidc",
|
||||||
|
subject="sub-1",
|
||||||
|
email="oidc1@example.com",
|
||||||
|
issuer="https://issuer-v2.example.com",
|
||||||
|
details={"groups": ["users"]},
|
||||||
|
)
|
||||||
|
self.assertEqual(refreshed.id, identity.id)
|
||||||
|
self.assertEqual(refreshed.issuer, "https://issuer-v2.example.com")
|
||||||
|
self.assertEqual(refreshed.details, {"groups": ["users"]})
|
||||||
|
|
||||||
|
def test_upsert_external_identity_rejects_conflicts(self):
|
||||||
|
User = get_user_model()
|
||||||
|
user1 = User.objects.create_user(username="oidc2", email="oidc2@example.com", password="pass12345")
|
||||||
|
user2 = User.objects.create_user(username="oidc3", email="oidc3@example.com", password="pass12345")
|
||||||
|
upsert_external_identity(
|
||||||
|
user=user1,
|
||||||
|
provider_type=ExternalIdentity.ProviderType.OIDC,
|
||||||
|
provider_id="oidc",
|
||||||
|
subject="sub-shared",
|
||||||
|
email="oidc2@example.com",
|
||||||
|
)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
upsert_external_identity(
|
||||||
|
user=user2,
|
||||||
|
provider_type=ExternalIdentity.ProviderType.OIDC,
|
||||||
|
provider_id="oidc",
|
||||||
|
subject="sub-shared",
|
||||||
|
email="oidc3@example.com",
|
||||||
|
)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
upsert_external_identity(
|
||||||
|
user=user1,
|
||||||
|
provider_type=ExternalIdentity.ProviderType.OIDC,
|
||||||
|
provider_id="oidc",
|
||||||
|
subject="sub-shared",
|
||||||
|
email="different@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleMappingTests(TestCase):
|
||||||
|
def test_apply_optional_role_mapping_promotes_and_demotes(self):
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.create_user(username="role-map", email="role-map@example.com", password="pass12345")
|
||||||
|
|
||||||
|
promoted = apply_optional_role_mapping(
|
||||||
|
user=user,
|
||||||
|
groups={"platform-admins"},
|
||||||
|
enabled=True,
|
||||||
|
admin_groups={"platform-admins"},
|
||||||
|
demote_on_miss=False,
|
||||||
|
)
|
||||||
|
user.refresh_from_db()
|
||||||
|
self.assertEqual(promoted, ROLE_ADMIN)
|
||||||
|
self.assertEqual(get_user_role(user), ROLE_ADMIN)
|
||||||
|
|
||||||
|
demoted = apply_optional_role_mapping(
|
||||||
|
user=user,
|
||||||
|
groups={"non-admin"},
|
||||||
|
enabled=True,
|
||||||
|
admin_groups={"platform-admins"},
|
||||||
|
demote_on_miss=True,
|
||||||
|
)
|
||||||
|
user.refresh_from_db()
|
||||||
|
self.assertEqual(demoted, ROLE_USER)
|
||||||
|
self.assertEqual(get_user_role(user), ROLE_USER)
|
||||||
|
|
||||||
|
def test_apply_optional_role_mapping_noop_paths(self):
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.create_user(username="role-map2", email="role-map2@example.com", password="pass12345")
|
||||||
|
self.assertIsNone(
|
||||||
|
apply_optional_role_mapping(
|
||||||
|
user=user,
|
||||||
|
groups={"anything"},
|
||||||
|
enabled=False,
|
||||||
|
admin_groups={"platform-admins"},
|
||||||
|
demote_on_miss=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
apply_optional_role_mapping(
|
||||||
|
user=user,
|
||||||
|
groups={"platform-admins"},
|
||||||
|
enabled=True,
|
||||||
|
admin_groups=set(),
|
||||||
|
demote_on_miss=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_set_user_role_invalid_raises(self):
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.create_user(username="role-map3", email="role-map3@example.com", password="pass12345")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
set_user_role(user, "invalid-role")
|
||||||
|
|
||||||
|
|
||||||
|
class _DummySocialLogin:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
provider: str = "github",
|
||||||
|
uid: str = "subject-1",
|
||||||
|
state: dict | None = None,
|
||||||
|
is_existing: bool = False,
|
||||||
|
existing_user=None,
|
||||||
|
email_verified: bool = True,
|
||||||
|
):
|
||||||
|
self.account = SimpleNamespace(
|
||||||
|
provider=provider,
|
||||||
|
uid=uid,
|
||||||
|
extra_data={"email": email, "name": "Test User"},
|
||||||
|
)
|
||||||
|
self.user = existing_user or SimpleNamespace(email=email, pk=None)
|
||||||
|
self.state = state or {}
|
||||||
|
self.is_existing = is_existing
|
||||||
|
self.email_addresses = [SimpleNamespace(email=email, verified=email_verified)]
|
||||||
|
self.connected_user = None
|
||||||
|
|
||||||
|
def connect(self, request, user) -> None:
|
||||||
|
self.connected_user = user
|
||||||
|
self.user = user
|
||||||
|
self.is_existing = True
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAdapterTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.adapter = KeywardenSocialAccountAdapter()
|
||||||
|
|
||||||
|
def _request(self, *, user=None, path: str = "/accounts/sso/github/login/callback/"):
|
||||||
|
request = self.factory.get(path)
|
||||||
|
session_middleware = SessionMiddleware(lambda r: None)
|
||||||
|
session_middleware.process_request(request)
|
||||||
|
request.session.save()
|
||||||
|
request._messages = FallbackStorage(request)
|
||||||
|
request.user = user or AnonymousUser()
|
||||||
|
return request
|
||||||
|
|
||||||
|
def test_login_requires_prelinked_github_identity(self):
|
||||||
|
User = get_user_model()
|
||||||
|
User.objects.create_user(username="social1", email="social1@example.com", password="pass12345")
|
||||||
|
request = self._request()
|
||||||
|
sociallogin = _DummySocialLogin(
|
||||||
|
email="social1@example.com",
|
||||||
|
state={"process": "login"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(ImmediateHttpResponse):
|
||||||
|
self.adapter.pre_social_login(request, sociallogin)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
ExternalIdentity.objects.filter(
|
||||||
|
provider_type=ExternalIdentity.ProviderType.SOCIAL,
|
||||||
|
provider_id="github",
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_connect_flow_links_github_to_current_user(self):
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="social2",
|
||||||
|
email="social2@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
request = self._request(user=user)
|
||||||
|
sociallogin = _DummySocialLogin(
|
||||||
|
email="gh-social2@example.net",
|
||||||
|
uid="github-sub-2",
|
||||||
|
state={"process": "connect"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.adapter.pre_social_login(request, sociallogin)
|
||||||
|
|
||||||
|
identity = ExternalIdentity.objects.get(
|
||||||
|
provider_type=ExternalIdentity.ProviderType.SOCIAL,
|
||||||
|
provider_id="github",
|
||||||
|
subject="github-sub-2",
|
||||||
|
)
|
||||||
|
self.assertEqual(identity.user_id, user.id)
|
||||||
|
self.assertEqual(identity.email_at_link, "gh-social2@example.net")
|
||||||
|
self.assertEqual(sociallogin.connected_user, user)
|
||||||
|
|
||||||
|
def test_login_succeeds_when_github_identity_is_linked(self):
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="social3",
|
||||||
|
email="social3@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
ExternalIdentity.objects.create(
|
||||||
|
user=user,
|
||||||
|
provider_type=ExternalIdentity.ProviderType.SOCIAL,
|
||||||
|
provider_id="github",
|
||||||
|
subject="github-sub-3",
|
||||||
|
email_at_link="gh-social3@example.net",
|
||||||
|
)
|
||||||
|
request = self._request()
|
||||||
|
sociallogin = _DummySocialLogin(
|
||||||
|
email="gh-social3@example.net",
|
||||||
|
uid="github-sub-3",
|
||||||
|
state={"process": "login"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.adapter.pre_social_login(request, sociallogin)
|
||||||
|
|
||||||
|
self.assertEqual(sociallogin.connected_user, user)
|
||||||
|
identity = ExternalIdentity.objects.get(
|
||||||
|
provider_type=ExternalIdentity.ProviderType.SOCIAL,
|
||||||
|
provider_id="github",
|
||||||
|
subject="github-sub-3",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(identity.last_login_at)
|
||||||
|
|
||||||
|
def test_connect_flow_allows_local_and_github_email_mismatch(self):
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="social4",
|
||||||
|
email="social4@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
request = self._request(user=user)
|
||||||
|
sociallogin = _DummySocialLogin(
|
||||||
|
email="different@example.com",
|
||||||
|
uid="github-sub-4",
|
||||||
|
state={"process": "connect"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.adapter.pre_social_login(request, sociallogin)
|
||||||
|
identity = ExternalIdentity.objects.get(
|
||||||
|
provider_type=ExternalIdentity.ProviderType.SOCIAL,
|
||||||
|
provider_id="github",
|
||||||
|
subject="github-sub-4",
|
||||||
|
)
|
||||||
|
self.assertEqual(identity.user_id, user.id)
|
||||||
|
self.assertEqual(identity.email_at_link, "different@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
class NativePasswordResetFormTests(TestCase):
|
||||||
|
def test_native_password_reset_excludes_sso_accounts(self):
|
||||||
|
User = get_user_model()
|
||||||
|
native_user = User.objects.create_user(
|
||||||
|
username="native-user",
|
||||||
|
email="native@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
sso_user = User.objects.create_user(
|
||||||
|
username="sso-user",
|
||||||
|
email="sso@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
ExternalIdentity.objects.create(
|
||||||
|
user=sso_user,
|
||||||
|
provider_type=ExternalIdentity.ProviderType.OIDC,
|
||||||
|
provider_id="oidc",
|
||||||
|
subject="oidc-subject",
|
||||||
|
email_at_link="sso@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
native_form = NativePasswordResetForm(data={"email": "native@example.com"})
|
||||||
|
self.assertTrue(native_form.is_valid())
|
||||||
|
native_users = list(native_form.get_users("native@example.com"))
|
||||||
|
self.assertEqual([user.pk for user in native_users], [native_user.pk])
|
||||||
|
|
||||||
|
sso_form = NativePasswordResetForm(data={"email": "sso@example.com"})
|
||||||
|
self.assertTrue(sso_form.is_valid())
|
||||||
|
sso_users = list(sso_form.get_users("sso@example.com"))
|
||||||
|
self.assertEqual(sso_users, [])
|
||||||
|
|
||||||
|
|
||||||
|
class NativeEmailVerificationTests(TestCase):
|
||||||
|
def test_email_verification_marks_native_account_verified(self):
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="verify-native",
|
||||||
|
email="verify-native@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
token = signing.dumps(
|
||||||
|
{"uid": user.pk, "email": "verify-native@example.com"},
|
||||||
|
salt="accounts.native-email-verification",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("accounts:email_verify", kwargs={"token": token}))
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
security = NativeAccountSecurity.objects.get(user=user)
|
||||||
|
self.assertTrue(security.email_verified)
|
||||||
|
self.assertIsNotNone(security.email_verified_at)
|
||||||
|
|
||||||
|
def test_email_verification_does_not_verify_sso_account(self):
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="verify-sso",
|
||||||
|
email="verify-sso@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
ExternalIdentity.objects.create(
|
||||||
|
user=user,
|
||||||
|
provider_type=ExternalIdentity.ProviderType.OIDC,
|
||||||
|
provider_id="oidc",
|
||||||
|
subject="sso-subject-verify",
|
||||||
|
email_at_link="verify-sso@example.com",
|
||||||
|
)
|
||||||
|
token = signing.dumps(
|
||||||
|
{"uid": user.pk, "email": "verify-sso@example.com"},
|
||||||
|
salt="accounts.native-email-verification",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("accounts:email_verify", kwargs={"token": token}))
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertFalse(NativeAccountSecurity.objects.filter(user=user).exists())
|
||||||
@@ -6,6 +6,14 @@ app_name = "accounts"
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("login/", views.login_view, name="login"),
|
path("login/", views.login_view, name="login"),
|
||||||
path("logout/", views.logout_view, name="logout"),
|
path("logout/", views.logout_view, name="logout"),
|
||||||
|
path("password-reset/", views.password_reset_view, name="password_reset"),
|
||||||
|
path("password-reset/done/", views.password_reset_done_view, name="password_reset_done"),
|
||||||
|
path(
|
||||||
|
"password-reset/confirm/<uidb64>/<token>/",
|
||||||
|
views.password_reset_confirm_view,
|
||||||
|
name="password_reset_confirm",
|
||||||
|
),
|
||||||
|
path("password-reset/complete/", views.password_reset_complete_view, name="password_reset_complete"),
|
||||||
|
path("email/verify/<str:token>/", views.email_verify_view, name="email_verify"),
|
||||||
path("profile/", views.profile, name="profile"),
|
path("profile/", views.profile, name="profile"),
|
||||||
]
|
]
|
||||||
|
|
||||||
+277
-58
@@ -1,17 +1,76 @@
|
|||||||
|
from datetime import timedelta
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.auth import logout
|
from django.contrib.auth import logout
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core import signing
|
||||||
|
from django.core.mail import send_mail
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.urls import reverse, reverse_lazy
|
||||||
|
from django.utils import timezone
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from apps.keys.certificates import issue_certificate_for_key
|
from apps.keys.certificates import issue_certificate_for_key
|
||||||
from apps.keys.models import SSHKey
|
from apps.keys.models import SSHKey
|
||||||
|
|
||||||
from .forms import ErasureRequestForm, SSHKeyForm
|
from .forms import ErasureRequestForm, NativePasswordResetForm, SSHKeyForm, StyledSetPasswordForm
|
||||||
from .models import ErasureRequest, ExternalIdentity
|
from .identity import normalize_email
|
||||||
|
from .models import ErasureRequest, ExternalIdentity, NativeAccountSecurity
|
||||||
|
|
||||||
|
_EMAIL_VERIFICATION_SIGNER_SALT = "accounts.native-email-verification"
|
||||||
|
|
||||||
|
|
||||||
|
def _unlink_social_account(user, provider_id: str) -> None:
|
||||||
|
try:
|
||||||
|
from allauth.socialaccount.models import SocialAccount
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
SocialAccount.objects.filter(user=user, provider=provider_id).delete()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_sso_identity(user) -> bool:
|
||||||
|
return ExternalIdentity.objects.filter(user=user).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _idp_account_portal_url() -> str:
|
||||||
|
explicit = (getattr(settings, "KEYWARDEN_IDP_ACCOUNT_PORTAL_URL", "") or "").strip()
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
|
||||||
|
issuer = (getattr(settings, "KEYWARDEN_OIDC_ISSUER", "") or "").strip()
|
||||||
|
if issuer:
|
||||||
|
return issuer
|
||||||
|
|
||||||
|
endpoint = (
|
||||||
|
getattr(settings, "OIDC_OP_AUTHORIZATION_ENDPOINT", "")
|
||||||
|
or getattr(settings, "OIDC_OP_DISCOVERY_ENDPOINT", "")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
if not endpoint:
|
||||||
|
return ""
|
||||||
|
parsed = urlparse(endpoint)
|
||||||
|
if not parsed.scheme or not parsed.netloc:
|
||||||
|
return ""
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}/"
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_email_verification_token(user) -> str:
|
||||||
|
payload = {"uid": user.pk, "email": normalize_email(user.email)}
|
||||||
|
return signing.dumps(payload, salt=_EMAIL_VERIFICATION_SIGNER_SALT)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_email_verification_token(token: str):
|
||||||
|
max_age = int(getattr(settings, "KEYWARDEN_EMAIL_VERIFICATION_TOKEN_MAX_AGE_SECONDS", 86400))
|
||||||
|
payload = signing.loads(token, salt=_EMAIL_VERIFICATION_SIGNER_SALT, max_age=max_age)
|
||||||
|
user_id = payload.get("uid")
|
||||||
|
email = normalize_email(payload.get("email"))
|
||||||
|
if not user_id or not email:
|
||||||
|
raise signing.BadSignature("Invalid token payload.")
|
||||||
|
return user_id, email
|
||||||
|
|
||||||
|
|
||||||
def _oidc_provider_label() -> str:
|
def _oidc_provider_label() -> str:
|
||||||
@@ -62,7 +121,85 @@ def profile(request):
|
|||||||
key_form.add_error("public_key", "Key already exists.")
|
key_form.add_error("public_key", "Key already exists.")
|
||||||
except Exception:
|
except Exception:
|
||||||
key_form.add_error(None, "Certificate issuance failed.")
|
key_form.add_error(None, "Certificate issuance failed.")
|
||||||
else:
|
elif form_type == "unlink_github":
|
||||||
|
key_form = SSHKeyForm()
|
||||||
|
erasure_form = ErasureRequestForm()
|
||||||
|
deleted_count, _ = ExternalIdentity.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
provider_type=ExternalIdentity.ProviderType.SOCIAL,
|
||||||
|
provider_id__iexact="github",
|
||||||
|
).delete()
|
||||||
|
_unlink_social_account(request.user, "github")
|
||||||
|
if deleted_count:
|
||||||
|
messages.success(request, "GitHub login was unlinked from your account.")
|
||||||
|
else:
|
||||||
|
messages.info(request, "No GitHub link was found on your account.")
|
||||||
|
return redirect("accounts:profile")
|
||||||
|
elif form_type == "send_email_verification":
|
||||||
|
key_form = SSHKeyForm()
|
||||||
|
erasure_form = ErasureRequestForm()
|
||||||
|
idp_account_portal_url = _idp_account_portal_url()
|
||||||
|
if _has_sso_identity(request.user):
|
||||||
|
if idp_account_portal_url:
|
||||||
|
messages.info(
|
||||||
|
request,
|
||||||
|
f"This account uses SSO. Verify your email in your identity provider: {idp_account_portal_url}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.info(
|
||||||
|
request,
|
||||||
|
"This account uses SSO. Verify your email in your configured identity provider.",
|
||||||
|
)
|
||||||
|
return redirect("accounts:profile")
|
||||||
|
|
||||||
|
if not bool(getattr(settings, "KEYWARDEN_EMAIL_VERIFICATION_ENABLED", True)):
|
||||||
|
messages.info(request, "Email verification is disabled in this environment.")
|
||||||
|
return redirect("accounts:profile")
|
||||||
|
|
||||||
|
security, _ = NativeAccountSecurity.objects.get_or_create(user=request.user)
|
||||||
|
if security.email_verified:
|
||||||
|
messages.success(request, "Your email is already verified.")
|
||||||
|
return redirect("accounts:profile")
|
||||||
|
|
||||||
|
cooldown_seconds = int(getattr(settings, "KEYWARDEN_EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS", 300))
|
||||||
|
now = timezone.now()
|
||||||
|
if (
|
||||||
|
security.verification_email_sent_at
|
||||||
|
and (now - security.verification_email_sent_at) < timedelta(seconds=cooldown_seconds)
|
||||||
|
):
|
||||||
|
messages.info(
|
||||||
|
request,
|
||||||
|
f"Verification email recently sent. Please wait up to {cooldown_seconds} seconds before retrying.",
|
||||||
|
)
|
||||||
|
return redirect("accounts:profile")
|
||||||
|
|
||||||
|
token = _issue_email_verification_token(request.user)
|
||||||
|
verification_link = request.build_absolute_uri(
|
||||||
|
reverse("accounts:email_verify", kwargs={"token": token})
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"user": request.user,
|
||||||
|
"verification_link": verification_link,
|
||||||
|
"expires_seconds": int(getattr(settings, "KEYWARDEN_EMAIL_VERIFICATION_TOKEN_MAX_AGE_SECONDS", 86400)),
|
||||||
|
}
|
||||||
|
subject = render_to_string("accounts/email_verification_subject.txt", context).strip()
|
||||||
|
body = render_to_string("accounts/email_verification_email.txt", context)
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", "no-reply@localhost"),
|
||||||
|
recipient_list=[request.user.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
messages.error(request, "Failed to send verification email. Contact an administrator.")
|
||||||
|
return redirect("accounts:profile")
|
||||||
|
security.verification_email_sent_at = now
|
||||||
|
security.save(update_fields=["verification_email_sent_at", "updated_at"])
|
||||||
|
messages.success(request, f"Verification email sent to {request.user.email}.")
|
||||||
|
return redirect("accounts:profile")
|
||||||
|
elif form_type == "erasure":
|
||||||
key_form = SSHKeyForm()
|
key_form = SSHKeyForm()
|
||||||
erasure_form = ErasureRequestForm(request.POST)
|
erasure_form = ErasureRequestForm(request.POST)
|
||||||
if erasure_form.is_valid():
|
if erasure_form.is_valid():
|
||||||
@@ -74,70 +211,53 @@ def profile(request):
|
|||||||
reason=erasure_form.cleaned_data["reason"].strip(),
|
reason=erasure_form.cleaned_data["reason"].strip(),
|
||||||
)
|
)
|
||||||
return redirect("accounts:profile")
|
return redirect("accounts:profile")
|
||||||
|
else:
|
||||||
|
key_form = SSHKeyForm()
|
||||||
|
erasure_form = ErasureRequestForm()
|
||||||
else:
|
else:
|
||||||
erasure_form = ErasureRequestForm()
|
erasure_form = ErasureRequestForm()
|
||||||
key_form = SSHKeyForm()
|
key_form = SSHKeyForm()
|
||||||
|
|
||||||
ssh_keys = SSHKey.objects.filter(user=request.user).order_by("-created_at")
|
ssh_keys = SSHKey.objects.filter(user=request.user).order_by("-created_at")
|
||||||
external_identities = ExternalIdentity.objects.filter(user=request.user).order_by("-last_login_at", "provider_id")
|
external_identities = ExternalIdentity.objects.filter(user=request.user).order_by("-last_login_at", "provider_id")
|
||||||
|
has_sso_identity = external_identities.exists()
|
||||||
oidc_identity = external_identities.filter(provider_type=ExternalIdentity.ProviderType.OIDC).first()
|
oidc_identity = external_identities.filter(provider_type=ExternalIdentity.ProviderType.OIDC).first()
|
||||||
social_identity_map = {
|
github_identity = external_identities.filter(
|
||||||
identity.provider_id.lower(): identity
|
provider_type=ExternalIdentity.ProviderType.SOCIAL,
|
||||||
for identity in external_identities.filter(provider_type=ExternalIdentity.ProviderType.SOCIAL)
|
provider_id__iexact="github",
|
||||||
}
|
).first()
|
||||||
configured_social_map = {
|
github_provider = next(
|
||||||
provider.get("id", "").strip().lower(): provider
|
(
|
||||||
for provider in getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", [])
|
provider
|
||||||
if provider.get("id")
|
for provider in getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", [])
|
||||||
}
|
if (provider.get("id", "").strip().lower() == "github")
|
||||||
|
),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
github_login_url = github_provider.get("login_url", "")
|
||||||
|
github_connect_url = github_provider.get("connect_url", "")
|
||||||
|
if not github_connect_url and github_login_url:
|
||||||
|
github_connect_url = f"{github_login_url}?process=connect"
|
||||||
|
|
||||||
social_provider_defaults = [
|
native_security = None
|
||||||
("github", "GitHub"),
|
if not has_sso_identity:
|
||||||
("google", "Google"),
|
native_security, _ = NativeAccountSecurity.objects.get_or_create(user=request.user)
|
||||||
("apple", "Apple"),
|
idp_account_portal_url = _idp_account_portal_url()
|
||||||
("gitlab", "GitLab"),
|
|
||||||
("microsoft", "Microsoft Entra ID"),
|
|
||||||
]
|
|
||||||
social_provider_cards = []
|
|
||||||
seen_provider_ids = set()
|
|
||||||
for provider_id, provider_name in social_provider_defaults:
|
|
||||||
provider_config = configured_social_map.get(provider_id, {})
|
|
||||||
identity = social_identity_map.get(provider_id)
|
|
||||||
social_provider_cards.append(
|
|
||||||
{
|
|
||||||
"id": provider_id,
|
|
||||||
"name": provider_name,
|
|
||||||
"configured": bool(provider_config.get("login_url")),
|
|
||||||
"login_url": provider_config.get("login_url", ""),
|
|
||||||
"linked": bool(identity),
|
|
||||||
"identity": identity,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
seen_provider_ids.add(provider_id)
|
|
||||||
|
|
||||||
for provider_id, provider_config in configured_social_map.items():
|
|
||||||
if provider_id in seen_provider_ids:
|
|
||||||
continue
|
|
||||||
identity = social_identity_map.get(provider_id)
|
|
||||||
social_provider_cards.append(
|
|
||||||
{
|
|
||||||
"id": provider_id,
|
|
||||||
"name": provider_config.get("name", provider_id.title()),
|
|
||||||
"configured": bool(provider_config.get("login_url")),
|
|
||||||
"login_url": provider_config.get("login_url", ""),
|
|
||||||
"linked": bool(identity),
|
|
||||||
"identity": identity,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"user": request.user,
|
"user": request.user,
|
||||||
"auth_mode": getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid"),
|
"auth_mode": getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid"),
|
||||||
"oidc_enabled": bool(getattr(settings, "KEYWARDEN_OIDC_ENABLED", False)),
|
"oidc_enabled": bool(getattr(settings, "KEYWARDEN_OIDC_LOGIN_ENABLED", False)),
|
||||||
"oidc_identity": oidc_identity,
|
"oidc_identity": oidc_identity,
|
||||||
"oidc_provider_id": getattr(settings, "KEYWARDEN_OIDC_PROVIDER_ID", "oidc"),
|
"oidc_provider_id": getattr(settings, "KEYWARDEN_OIDC_PROVIDER_ID", "oidc"),
|
||||||
"oidc_issuer": getattr(settings, "KEYWARDEN_OIDC_ISSUER", ""),
|
"oidc_issuer": getattr(settings, "KEYWARDEN_OIDC_ISSUER", ""),
|
||||||
"social_provider_cards": social_provider_cards,
|
"github_social_configured": bool(github_login_url),
|
||||||
|
"github_social_connect_url": github_connect_url,
|
||||||
|
"github_identity": github_identity,
|
||||||
|
"native_account_security": native_security,
|
||||||
|
"native_security_enabled": not has_sso_identity,
|
||||||
|
"email_verification_enabled": bool(getattr(settings, "KEYWARDEN_EMAIL_VERIFICATION_ENABLED", True)),
|
||||||
|
"idp_account_portal_url": idp_account_portal_url,
|
||||||
"erasure_request": erasure_request,
|
"erasure_request": erasure_request,
|
||||||
"erasure_form": erasure_form,
|
"erasure_form": erasure_form,
|
||||||
"key_form": key_form,
|
"key_form": key_form,
|
||||||
@@ -149,30 +269,39 @@ def profile(request):
|
|||||||
|
|
||||||
def login_view(request):
|
def login_view(request):
|
||||||
auth_mode = getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid")
|
auth_mode = getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid")
|
||||||
if auth_mode == "oidc":
|
oidc_login_enabled = bool(getattr(settings, "KEYWARDEN_OIDC_LOGIN_ENABLED", False))
|
||||||
|
social_providers = (
|
||||||
|
getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", [])
|
||||||
|
if auth_mode == "hybrid"
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
if auth_mode == "oidc" and oidc_login_enabled:
|
||||||
return redirect("/oidc/authenticate/")
|
return redirect("/oidc/authenticate/")
|
||||||
|
|
||||||
has_sso_options = bool(
|
has_sso_options = bool(
|
||||||
getattr(settings, "KEYWARDEN_OIDC_ENABLED", False)
|
oidc_login_enabled
|
||||||
or getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", [])
|
or social_providers
|
||||||
)
|
)
|
||||||
show_native_login = (
|
show_native_login = (
|
||||||
request.method == "POST"
|
request.method == "POST"
|
||||||
or request.GET.get("native") == "1"
|
or request.GET.get("native") == "1"
|
||||||
or (auth_mode == "native" and not has_sso_options)
|
or auth_mode == "native"
|
||||||
)
|
)
|
||||||
|
|
||||||
# native or hybrid -> render Django's built-in login view
|
# native or hybrid -> render Django's built-in login view
|
||||||
return auth_views.LoginView.as_view(
|
return auth_views.LoginView.as_view(
|
||||||
template_name="accounts/login.html",
|
template_name="accounts/login.html",
|
||||||
extra_context={
|
extra_context={
|
||||||
"social_providers": getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", []),
|
"social_providers": social_providers,
|
||||||
"oidc_enabled": bool(getattr(settings, "KEYWARDEN_OIDC_ENABLED", False)),
|
"oidc_enabled": oidc_login_enabled,
|
||||||
"oidc_login_url": "/oidc/authenticate/",
|
"oidc_login_url": "/oidc/authenticate/",
|
||||||
"oidc_provider_label": _oidc_provider_label(),
|
"oidc_provider_label": _oidc_provider_label(),
|
||||||
"show_native_login": show_native_login,
|
"show_native_login": show_native_login,
|
||||||
"can_use_native_login": auth_mode in {"native", "hybrid"},
|
"can_use_native_login": auth_mode in {"native", "hybrid"},
|
||||||
"show_sso_choice": has_sso_options and not show_native_login,
|
"show_sso_choice": has_sso_options and not show_native_login,
|
||||||
|
"password_reset_enabled": auth_mode in {"native", "hybrid"},
|
||||||
|
"idp_account_portal_url": _idp_account_portal_url(),
|
||||||
},
|
},
|
||||||
)(request)
|
)(request)
|
||||||
|
|
||||||
@@ -180,3 +309,93 @@ def login_view(request):
|
|||||||
def logout_view(request):
|
def logout_view(request):
|
||||||
logout(request)
|
logout(request)
|
||||||
return redirect(getattr(settings, "LOGOUT_REDIRECT_URL", "/"))
|
return redirect(getattr(settings, "LOGOUT_REDIRECT_URL", "/"))
|
||||||
|
|
||||||
|
|
||||||
|
def password_reset_view(request):
|
||||||
|
return auth_views.PasswordResetView.as_view(
|
||||||
|
template_name="accounts/password_reset_form.html",
|
||||||
|
email_template_name="accounts/password_reset_email.txt",
|
||||||
|
subject_template_name="accounts/password_reset_subject.txt",
|
||||||
|
success_url=reverse_lazy("accounts:password_reset_done"),
|
||||||
|
form_class=NativePasswordResetForm,
|
||||||
|
extra_context={
|
||||||
|
"idp_account_portal_url": _idp_account_portal_url(),
|
||||||
|
},
|
||||||
|
)(request)
|
||||||
|
|
||||||
|
|
||||||
|
def password_reset_done_view(request):
|
||||||
|
return auth_views.PasswordResetDoneView.as_view(
|
||||||
|
template_name="accounts/password_reset_done.html",
|
||||||
|
extra_context={
|
||||||
|
"idp_account_portal_url": _idp_account_portal_url(),
|
||||||
|
},
|
||||||
|
)(request)
|
||||||
|
|
||||||
|
|
||||||
|
def password_reset_confirm_view(request, uidb64, token):
|
||||||
|
return auth_views.PasswordResetConfirmView.as_view(
|
||||||
|
template_name="accounts/password_reset_confirm.html",
|
||||||
|
success_url=reverse_lazy("accounts:password_reset_complete"),
|
||||||
|
form_class=StyledSetPasswordForm,
|
||||||
|
)(request, uidb64=uidb64, token=token)
|
||||||
|
|
||||||
|
|
||||||
|
def password_reset_complete_view(request):
|
||||||
|
return auth_views.PasswordResetCompleteView.as_view(
|
||||||
|
template_name="accounts/password_reset_complete.html",
|
||||||
|
)(request)
|
||||||
|
|
||||||
|
|
||||||
|
def email_verify_view(request, token):
|
||||||
|
if not bool(getattr(settings, "KEYWARDEN_EMAIL_VERIFICATION_ENABLED", True)):
|
||||||
|
messages.info(request, "Email verification is disabled in this environment.")
|
||||||
|
return redirect("accounts:login")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id, token_email = _resolve_email_verification_token(token)
|
||||||
|
except signing.SignatureExpired:
|
||||||
|
messages.error(request, "This verification link has expired. Request a new email from your profile.")
|
||||||
|
return redirect("accounts:login")
|
||||||
|
except signing.BadSignature:
|
||||||
|
messages.error(request, "Invalid verification link.")
|
||||||
|
return redirect("accounts:login")
|
||||||
|
|
||||||
|
user = (
|
||||||
|
request.user
|
||||||
|
if request.user.is_authenticated and request.user.pk == user_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if user is None:
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
user = get_user_model().objects.filter(pk=user_id, is_active=True).first()
|
||||||
|
if not user:
|
||||||
|
messages.error(request, "Verification target account was not found.")
|
||||||
|
return redirect("accounts:login")
|
||||||
|
|
||||||
|
if _has_sso_identity(user):
|
||||||
|
idp_account_portal_url = _idp_account_portal_url()
|
||||||
|
if idp_account_portal_url:
|
||||||
|
messages.info(
|
||||||
|
request,
|
||||||
|
f"This account is managed by SSO. Verify email at your identity provider: {idp_account_portal_url}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
messages.info(
|
||||||
|
request,
|
||||||
|
"This account is managed by SSO. Verify email at your identity provider.",
|
||||||
|
)
|
||||||
|
return redirect("accounts:profile" if request.user.is_authenticated else "accounts:login")
|
||||||
|
|
||||||
|
if normalize_email(user.email) != token_email:
|
||||||
|
messages.error(request, "Verification link does not match the current account email.")
|
||||||
|
return redirect("accounts:login")
|
||||||
|
|
||||||
|
security, _ = NativeAccountSecurity.objects.get_or_create(user=user)
|
||||||
|
if not security.email_verified:
|
||||||
|
security.email_verified = True
|
||||||
|
security.email_verified_at = timezone.now()
|
||||||
|
security.save(update_fields=["email_verified", "email_verified_at", "updated_at"])
|
||||||
|
|
||||||
|
messages.success(request, "Your email address has been verified.")
|
||||||
|
return redirect("accounts:profile" if request.user.is_authenticated else "accounts:login")
|
||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.test import RequestFactory, TestCase
|
from django.test import RequestFactory, TestCase
|
||||||
|
|
||||||
|
from apps.audit.admin import AuditEventTypeAdminForm
|
||||||
from apps.audit.matching import find_matching_event_type
|
from apps.audit.matching import find_matching_event_type
|
||||||
from apps.audit.middleware import ApiAuditLogMiddleware
|
from apps.audit.middleware import ApiAuditLogMiddleware
|
||||||
from apps.audit.models import AuditEventType, AuditLog
|
from apps.audit.models import AuditEventType, AuditLog
|
||||||
@@ -84,3 +85,58 @@ class AuditEventMatchingTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIsNotNone(matched)
|
self.assertIsNotNone(matched)
|
||||||
self.assertEqual(matched.id, event_type.id)
|
self.assertEqual(matched.id, event_type.id)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditAdminFormTests(TestCase):
|
||||||
|
def test_clean_endpoints_text_normalizes_lines(self) -> None:
|
||||||
|
form = AuditEventTypeAdminForm(
|
||||||
|
data={
|
||||||
|
"key": "admin_form_test",
|
||||||
|
"title": "Admin Form Test",
|
||||||
|
"description": "",
|
||||||
|
"kind": AuditEventType.Kind.API,
|
||||||
|
"default_severity": AuditEventType.Severity.INFO,
|
||||||
|
"endpoints_text": " /api/v1/a \n\nGET /api/v1/b \n",
|
||||||
|
"ip_whitelist_enabled": False,
|
||||||
|
"ip_whitelist_text": "",
|
||||||
|
"ip_blacklist_enabled": False,
|
||||||
|
"ip_blacklist_text": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), form.errors)
|
||||||
|
self.assertEqual(form.cleaned_data["endpoints_text"], "/api/v1/a\nGET /api/v1/b")
|
||||||
|
|
||||||
|
def test_form_save_populates_endpoint_and_ip_lists(self) -> None:
|
||||||
|
form = AuditEventTypeAdminForm(
|
||||||
|
data={
|
||||||
|
"key": "admin_form_save",
|
||||||
|
"title": "Admin Form Save",
|
||||||
|
"description": "",
|
||||||
|
"kind": AuditEventType.Kind.API,
|
||||||
|
"default_severity": AuditEventType.Severity.WARNING,
|
||||||
|
"endpoints_text": "/api/v1/a\nPOST /api/v1/b",
|
||||||
|
"ip_whitelist_enabled": True,
|
||||||
|
"ip_whitelist_text": "10.0.0.1\n10.0.0.0/24",
|
||||||
|
"ip_blacklist_enabled": True,
|
||||||
|
"ip_blacklist_text": "203.0.113.10",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(form.is_valid(), form.errors)
|
||||||
|
instance = form.save()
|
||||||
|
self.assertEqual(instance.endpoints, ["/api/v1/a", "POST /api/v1/b"])
|
||||||
|
self.assertEqual(instance.ip_whitelist, ["10.0.0.1", "10.0.0.0/24"])
|
||||||
|
self.assertEqual(instance.ip_blacklist, ["203.0.113.10"])
|
||||||
|
|
||||||
|
def test_form_initializes_text_fields_from_instance(self) -> None:
|
||||||
|
instance = AuditEventType.objects.create(
|
||||||
|
key="admin_form_init",
|
||||||
|
title="Admin Form Init",
|
||||||
|
kind=AuditEventType.Kind.WEBSOCKET,
|
||||||
|
endpoints=["/ws/servers/*/shell/"],
|
||||||
|
ip_whitelist=["10.0.0.1"],
|
||||||
|
ip_blacklist=["203.0.113.10"],
|
||||||
|
)
|
||||||
|
form = AuditEventTypeAdminForm(instance=instance)
|
||||||
|
self.assertEqual(form.fields["endpoints_text"].initial, "/ws/servers/*/shell/")
|
||||||
|
self.assertEqual(form.fields["ip_whitelist_text"].initial, "10.0.0.1")
|
||||||
|
self.assertEqual(form.fields["ip_blacklist_text"].initial, "203.0.113.10")
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SECRET_DIR = "/var/lib/keywarden/ca-keys"
|
||||||
|
|
||||||
|
|
||||||
|
def secret_dir() -> str:
|
||||||
|
path = str(getattr(settings, "KEYWARDEN_CA_KEY_DIR", DEFAULT_SECRET_DIR) or "").strip()
|
||||||
|
if not path:
|
||||||
|
path = DEFAULT_SECRET_DIR
|
||||||
|
os.makedirs(path, mode=0o700, exist_ok=True)
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o700)
|
||||||
|
except PermissionError:
|
||||||
|
# Best-effort hardening when process owner cannot change mode.
|
||||||
|
pass
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_pem(value: str) -> bool:
|
||||||
|
return str(value or "").lstrip().startswith("-----BEGIN ")
|
||||||
|
|
||||||
|
|
||||||
|
def secret_ref_for(path: str) -> str:
|
||||||
|
return f"file://{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_secret_ref(value: str) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if text.lower().startswith("file://"):
|
||||||
|
return text[7:]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def write_secret_file(prefix: str, payload: str) -> str:
|
||||||
|
target_dir = secret_dir()
|
||||||
|
token = secrets.token_hex(16)
|
||||||
|
path = os.path.join(target_dir, f"{prefix}-{token}.pem")
|
||||||
|
with open(path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(payload)
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
return secret_ref_for(path)
|
||||||
|
|
||||||
|
|
||||||
|
def read_secret_ref(value: str, *, label: str) -> str:
|
||||||
|
path = parse_secret_ref(value)
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
return handle.read()
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise RuntimeError(f"{label} file missing: {path}") from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"{label} file unreadable: {path}") from exc
|
||||||
@@ -41,7 +41,7 @@ def issue_certificate_for_key(key: SSHKey, created_by=None) -> SSHCertificate:
|
|||||||
safe_name = _sanitize_label(key.name or "key")
|
safe_name = _sanitize_label(key.name or "key")
|
||||||
identity = f"keywarden-cert-{key.user_id}-{safe_name}-{key.id}"
|
identity = f"keywarden-cert-{key.user_id}-{safe_name}-{key.id}"
|
||||||
cert_text = _sign_public_key(
|
cert_text = _sign_public_key(
|
||||||
ca_private_key=ca.private_key,
|
ca_private_key=ca.get_private_key(),
|
||||||
ca_public_key=ca.public_key,
|
ca_public_key=ca.public_key,
|
||||||
public_key=key.public_key,
|
public_key=key.public_key,
|
||||||
identity=identity,
|
identity=identity,
|
||||||
|
|||||||
+24
-2
@@ -12,6 +12,8 @@ from django.core.exceptions import ValidationError
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.core.secret_files import looks_like_pem, read_secret_ref, write_secret_file
|
||||||
|
|
||||||
|
|
||||||
def parse_public_key(public_key: str) -> tuple[str, str, str]:
|
def parse_public_key(public_key: str) -> tuple[str, str, str]:
|
||||||
trimmed = (public_key or "").strip()
|
trimmed = (public_key or "").strip()
|
||||||
@@ -104,8 +106,28 @@ class SSHCertificateAuthority(models.Model):
|
|||||||
self.is_active = False
|
self.is_active = False
|
||||||
self.revoked_at = timezone.now()
|
self.revoked_at = timezone.now()
|
||||||
|
|
||||||
|
def get_private_key(self) -> str:
|
||||||
|
try:
|
||||||
|
file_payload = read_secret_ref(self.private_key, label="User CA private key")
|
||||||
|
except RuntimeError as exc:
|
||||||
|
# Recover gracefully when a secret-file reference points to a
|
||||||
|
# deleted key file (for example after volume resets).
|
||||||
|
if "file missing:" in str(exc):
|
||||||
|
file_payload = ""
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
if file_payload:
|
||||||
|
return file_payload
|
||||||
|
inline_payload = (self.private_key or "").strip()
|
||||||
|
if looks_like_pem(inline_payload):
|
||||||
|
self.private_key = write_secret_file("user-ca-key", inline_payload)
|
||||||
|
if self.pk:
|
||||||
|
self.save(update_fields=["private_key"])
|
||||||
|
return inline_payload
|
||||||
|
return ""
|
||||||
|
|
||||||
def ensure_material(self) -> None:
|
def ensure_material(self) -> None:
|
||||||
if self.public_key and self.private_key:
|
if self.public_key and self.get_private_key():
|
||||||
if not self.fingerprint:
|
if not self.fingerprint:
|
||||||
_, _, fingerprint = parse_public_key(self.public_key)
|
_, _, fingerprint = parse_public_key(self.public_key)
|
||||||
self.fingerprint = fingerprint
|
self.fingerprint = fingerprint
|
||||||
@@ -130,7 +152,7 @@ class SSHCertificateAuthority(models.Model):
|
|||||||
except subprocess.CalledProcessError as exc:
|
except subprocess.CalledProcessError as exc:
|
||||||
raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc
|
raise RuntimeError(f"ssh-keygen failed: {exc.stderr.decode('utf-8', 'ignore')}") from exc
|
||||||
with open(key_path, "r", encoding="utf-8") as handle:
|
with open(key_path, "r", encoding="utf-8") as handle:
|
||||||
self.private_key = handle.read()
|
self.private_key = write_secret_file("user-ca-key", handle.read())
|
||||||
with open(key_path + ".pub", "r", encoding="utf-8") as handle:
|
with open(key_path + ".pub", "r", encoding="utf-8") as handle:
|
||||||
self.public_key = handle.read().strip()
|
self.public_key = handle.read().strip()
|
||||||
_, _, fingerprint = parse_public_key(self.public_key)
|
_, _, fingerprint = parse_public_key(self.public_key)
|
||||||
|
|||||||
+166
-3
@@ -1,15 +1,48 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.conf import settings
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
|
||||||
|
try:
|
||||||
|
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
||||||
|
except ImportError:
|
||||||
|
UnfoldModelAdmin = admin.ModelAdmin
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from unfold.contrib.guardian.admin import GuardedModelAdmin
|
from unfold.contrib.guardian.admin import GuardedModelAdmin
|
||||||
except ImportError: # Fallback for older Unfold builds without guardian admin shim.
|
except ImportError: # Fallback for older Unfold builds without guardian admin shim.
|
||||||
from guardian.admin import GuardedModelAdmin as GuardianGuardedModelAdmin
|
from guardian.admin import GuardedModelAdmin as GuardianGuardedModelAdmin
|
||||||
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
|
||||||
|
|
||||||
class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin):
|
class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
from .models import AgentCertificateAuthority, EnrollmentToken, Server
|
from apps.access.models import AccessRequest
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
AgentCertificateAuthority,
|
||||||
|
EnrollmentToken,
|
||||||
|
Server,
|
||||||
|
ServerAccount,
|
||||||
|
ServerAuditLog,
|
||||||
|
ServerLogSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerLogSourceInline(admin.TabularInline):
|
||||||
|
model = ServerLogSource
|
||||||
|
extra = 0
|
||||||
|
fields = (
|
||||||
|
"enabled",
|
||||||
|
"kind",
|
||||||
|
"name",
|
||||||
|
"service_unit",
|
||||||
|
"file_path",
|
||||||
|
"parser",
|
||||||
|
"include_matches",
|
||||||
|
"exclude_matches",
|
||||||
|
"category_override",
|
||||||
|
"event_type_override",
|
||||||
|
)
|
||||||
|
ordering = ("kind", "name", "id")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Server)
|
@admin.register(Server)
|
||||||
@@ -29,6 +62,68 @@ class ServerAdmin(GuardedModelAdmin):
|
|||||||
"created_at",
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
)
|
)
|
||||||
|
inlines = (ServerLogSourceInline,)
|
||||||
|
|
||||||
|
def _large_delete_preview_threshold(self) -> int:
|
||||||
|
value = getattr(settings, "KEYWARDEN_ADMIN_DELETE_PREVIEW_MAX_ITEMS", 5000)
|
||||||
|
try:
|
||||||
|
threshold = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
threshold = 5000
|
||||||
|
return max(threshold, 1)
|
||||||
|
|
||||||
|
def _related_delete_counts(self, server_ids: list[int]) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
ServerAuditLog._meta.verbose_name_plural: ServerAuditLog.objects.filter(server_id__in=server_ids).count(),
|
||||||
|
ServerAccount._meta.verbose_name_plural: ServerAccount.objects.filter(server_id__in=server_ids).count(),
|
||||||
|
ServerLogSource._meta.verbose_name_plural: ServerLogSource.objects.filter(server_id__in=server_ids).count(),
|
||||||
|
AccessRequest._meta.verbose_name_plural: AccessRequest.objects.filter(server_id__in=server_ids).count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_deleted_objects(self, objs, request):
|
||||||
|
# Django's default nested object preview scales poorly for very large
|
||||||
|
# cascades (for example, many ServerAuditLog rows) and can cause
|
||||||
|
# worker timeouts/OOM during delete confirmation rendering.
|
||||||
|
if not request.user.is_superuser:
|
||||||
|
return super().get_deleted_objects(objs, request)
|
||||||
|
|
||||||
|
if hasattr(objs, "values_list"):
|
||||||
|
server_ids = list(objs.values_list("id", flat=True))
|
||||||
|
else:
|
||||||
|
server_ids = [obj.id for obj in objs if getattr(obj, "id", None) is not None]
|
||||||
|
if not server_ids:
|
||||||
|
return super().get_deleted_objects(objs, request)
|
||||||
|
|
||||||
|
related_counts = self._related_delete_counts(server_ids)
|
||||||
|
total_related = sum(related_counts.values())
|
||||||
|
if total_related < self._large_delete_preview_threshold():
|
||||||
|
return super().get_deleted_objects(objs, request)
|
||||||
|
|
||||||
|
model_count = {
|
||||||
|
self.model._meta.verbose_name_plural: len(server_ids),
|
||||||
|
}
|
||||||
|
deleted_objects = [f"{len(server_ids)} {self.model._meta.verbose_name_plural}"]
|
||||||
|
for label, count in related_counts.items():
|
||||||
|
if count:
|
||||||
|
model_count[label] = count
|
||||||
|
deleted_objects.append(f"{count} {label}")
|
||||||
|
deleted_objects.append(
|
||||||
|
"Large cascade detected. Related objects are summarized here to keep delete confirmation responsive."
|
||||||
|
)
|
||||||
|
return deleted_objects, model_count, set(), []
|
||||||
|
|
||||||
|
def _purge_server_audit_logs(self, server_ids: list[int]) -> None:
|
||||||
|
if not server_ids:
|
||||||
|
return
|
||||||
|
ServerAuditLog.objects.filter(server_id__in=server_ids).delete()
|
||||||
|
|
||||||
|
def delete_model(self, request, obj) -> None:
|
||||||
|
self._purge_server_audit_logs([obj.id])
|
||||||
|
super().delete_model(request, obj)
|
||||||
|
|
||||||
|
def delete_queryset(self, request, queryset) -> None:
|
||||||
|
self._purge_server_audit_logs(list(queryset.values_list("id", flat=True)))
|
||||||
|
super().delete_queryset(request, queryset)
|
||||||
|
|
||||||
def avatar(self, obj: Server):
|
def avatar(self, obj: Server):
|
||||||
if obj.image_url:
|
if obj.image_url:
|
||||||
@@ -46,13 +141,20 @@ class ServerAdmin(GuardedModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(EnrollmentToken)
|
@admin.register(EnrollmentToken)
|
||||||
class EnrollmentTokenAdmin(admin.ModelAdmin):
|
class EnrollmentTokenAdmin(UnfoldModelAdmin):
|
||||||
list_display = ("token", "created_at", "expires_at", "used_at", "server")
|
list_display = ("token", "created_at", "expires_at", "used_at", "server")
|
||||||
list_filter = ("created_at", "used_at")
|
list_filter = ("created_at", "used_at")
|
||||||
search_fields = ("token", "server__display_name", "server__hostname")
|
search_fields = ("token", "server__display_name", "server__hostname")
|
||||||
readonly_fields = ("token", "created_at", "used_at", "server", "created_by")
|
readonly_fields = ("token", "created_at", "used_at", "server", "created_by")
|
||||||
fields = ("token", "expires_at", "created_by", "created_at", "used_at", "server")
|
fields = ("token", "expires_at", "created_by", "created_at", "used_at", "server")
|
||||||
|
|
||||||
|
def get_fields(self, request, obj=None):
|
||||||
|
# Keep the add form minimal and deterministic in Unfold: token and
|
||||||
|
# metadata are generated server-side and shown on the change view.
|
||||||
|
if obj is None:
|
||||||
|
return ("expires_at",)
|
||||||
|
return self.fields
|
||||||
|
|
||||||
def save_model(self, request, obj, form, change) -> None:
|
def save_model(self, request, obj, form, change) -> None:
|
||||||
if not obj.pk:
|
if not obj.pk:
|
||||||
obj.ensure_token()
|
obj.ensure_token()
|
||||||
@@ -92,3 +194,64 @@ class AgentCertificateAuthorityAdmin(admin.ModelAdmin):
|
|||||||
for ca in queryset:
|
for ca in queryset:
|
||||||
ca.revoke()
|
ca.revoke()
|
||||||
ca.save(update_fields=["is_active", "revoked_at"])
|
ca.save(update_fields=["is_active", "revoked_at"])
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ServerAuditLog)
|
||||||
|
class ServerAuditLogAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"event_at",
|
||||||
|
"server",
|
||||||
|
"source_kind",
|
||||||
|
"source_name",
|
||||||
|
"category",
|
||||||
|
"event_type",
|
||||||
|
"username",
|
||||||
|
"source_ip",
|
||||||
|
"priority",
|
||||||
|
)
|
||||||
|
list_filter = ("source_kind", "category", "event_type", "priority", "server")
|
||||||
|
search_fields = ("message", "raw", "source_name", "username", "principal", "source_ip", "session_id")
|
||||||
|
readonly_fields = (
|
||||||
|
"server",
|
||||||
|
"event_at",
|
||||||
|
"received_at",
|
||||||
|
"source_kind",
|
||||||
|
"source_name",
|
||||||
|
"category",
|
||||||
|
"event_type",
|
||||||
|
"unit",
|
||||||
|
"priority",
|
||||||
|
"hostname",
|
||||||
|
"username",
|
||||||
|
"principal",
|
||||||
|
"source_ip",
|
||||||
|
"session_id",
|
||||||
|
"message",
|
||||||
|
"raw",
|
||||||
|
"fields",
|
||||||
|
)
|
||||||
|
ordering = ("-event_at", "-id")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ServerLogSource)
|
||||||
|
class ServerLogSourceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"server",
|
||||||
|
"enabled",
|
||||||
|
"kind",
|
||||||
|
"name",
|
||||||
|
"service_unit",
|
||||||
|
"file_path",
|
||||||
|
"parser",
|
||||||
|
"category_override",
|
||||||
|
"event_type_override",
|
||||||
|
)
|
||||||
|
list_filter = ("enabled", "kind", "server")
|
||||||
|
search_fields = (
|
||||||
|
"name",
|
||||||
|
"service_unit",
|
||||||
|
"file_path",
|
||||||
|
"parser",
|
||||||
|
"category_override",
|
||||||
|
"event_type_override",
|
||||||
|
)
|
||||||
+251
-52
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -34,8 +35,9 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
|||||||
self.tempdir = None
|
self.tempdir = None
|
||||||
self.tempdir_path = ""
|
self.tempdir_path = ""
|
||||||
self.ssh_started = False
|
self.ssh_started = False
|
||||||
self.key_cleanup_task = None
|
self.ssh_output_seen = False
|
||||||
self.key_paths = []
|
self.key_path = ""
|
||||||
|
self.cert_path = ""
|
||||||
self.system_username = ""
|
self.system_username = ""
|
||||||
self.shell_target = ""
|
self.shell_target = ""
|
||||||
self.server_id: int | None = None
|
self.server_id: int | None = None
|
||||||
@@ -91,9 +93,6 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
|||||||
if self.reader_task:
|
if self.reader_task:
|
||||||
self.reader_task.cancel()
|
self.reader_task.cancel()
|
||||||
self.reader_task = None
|
self.reader_task = None
|
||||||
if self.key_cleanup_task:
|
|
||||||
self.key_cleanup_task.cancel()
|
|
||||||
self.key_cleanup_task = None
|
|
||||||
if self.proc and self.proc.returncode is None:
|
if self.proc and self.proc.returncode is None:
|
||||||
self.proc.terminate()
|
self.proc.terminate()
|
||||||
try:
|
try:
|
||||||
@@ -101,8 +100,9 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
|||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
self.proc.kill()
|
self.proc.kill()
|
||||||
if self.tempdir_path and self.ssh_started:
|
if self.tempdir_path and self.ssh_started:
|
||||||
shutil.rmtree(self.tempdir_path, ignore_errors=True)
|
if not _is_truthy(getattr(settings, "KEYWARDEN_SHELL_PRESERVE_TMP", False)):
|
||||||
self.tempdir_path = ""
|
shutil.rmtree(self.tempdir_path, ignore_errors=True)
|
||||||
|
self.tempdir_path = ""
|
||||||
self.tempdir = None
|
self.tempdir = None
|
||||||
|
|
||||||
async def receive(self, text_data=None, bytes_data=None):
|
async def receive(self, text_data=None, bytes_data=None):
|
||||||
@@ -131,38 +131,16 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
|||||||
user,
|
user,
|
||||||
self.system_username,
|
self.system_username,
|
||||||
)
|
)
|
||||||
|
self.key_path = key_path
|
||||||
|
self.cert_path = cert_path
|
||||||
ssh_host = _format_ssh_host(self.shell_target)
|
ssh_host = _format_ssh_host(self.shell_target)
|
||||||
# Use a locked-down, non-interactive SSH invocation suitable for websockets.
|
# Build a locked-down SSH invocation and explicitly launch a remote shell.
|
||||||
command = [
|
command = _build_ssh_command(
|
||||||
"ssh",
|
key_path=key_path,
|
||||||
"-tt",
|
cert_path=cert_path,
|
||||||
"-i",
|
username=self.system_username,
|
||||||
key_path,
|
host=ssh_host,
|
||||||
"-o",
|
)
|
||||||
f"CertificateFile={cert_path}",
|
|
||||||
"-o",
|
|
||||||
"BatchMode=yes",
|
|
||||||
"-o",
|
|
||||||
"PasswordAuthentication=no",
|
|
||||||
"-o",
|
|
||||||
"KbdInteractiveAuthentication=no",
|
|
||||||
"-o",
|
|
||||||
"ChallengeResponseAuthentication=no",
|
|
||||||
"-o",
|
|
||||||
"PreferredAuthentications=publickey",
|
|
||||||
"-o",
|
|
||||||
"UserKnownHostsFile=/dev/null",
|
|
||||||
"-o",
|
|
||||||
"GlobalKnownHostsFile=/dev/null",
|
|
||||||
"-o",
|
|
||||||
"StrictHostKeyChecking=no",
|
|
||||||
"-o",
|
|
||||||
"VerifyHostKeyDNS=no",
|
|
||||||
"-o",
|
|
||||||
"LogLevel=ERROR",
|
|
||||||
f"{self.system_username}@{ssh_host}",
|
|
||||||
"/bin/bash",
|
|
||||||
]
|
|
||||||
self.proc = await asyncio.create_subprocess_exec(
|
self.proc = await asyncio.create_subprocess_exec(
|
||||||
*command,
|
*command,
|
||||||
stdin=asyncio.subprocess.PIPE,
|
stdin=asyncio.subprocess.PIPE,
|
||||||
@@ -170,8 +148,6 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
|||||||
stderr=asyncio.subprocess.STDOUT,
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
)
|
)
|
||||||
self.ssh_started = True
|
self.ssh_started = True
|
||||||
self.key_paths = [key_path, cert_path, f"{key_path}.pub"]
|
|
||||||
self.key_cleanup_task = asyncio.create_task(self._cleanup_keys_after_delay(5))
|
|
||||||
self.reader_task = asyncio.create_task(self._stream_output())
|
self.reader_task = asyncio.create_task(self._stream_output())
|
||||||
|
|
||||||
async def _stream_output(self):
|
async def _stream_output(self):
|
||||||
@@ -182,21 +158,104 @@ class ShellConsumer(AsyncWebsocketConsumer):
|
|||||||
chunk = await self.proc.stdout.read(4096)
|
chunk = await self.proc.stdout.read(4096)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
|
self.ssh_output_seen = True
|
||||||
await self.send(bytes_data=chunk)
|
await self.send(bytes_data=chunk)
|
||||||
|
if self.proc:
|
||||||
|
exit_code = await self.proc.wait()
|
||||||
|
if exit_code != 0 and not self.ssh_output_seen:
|
||||||
|
if _is_truthy(getattr(settings, "KEYWARDEN_SHELL_DEBUG", False)):
|
||||||
|
await self._send_ssh_failure_diagnostics(exit_code)
|
||||||
|
else:
|
||||||
|
await self.send(
|
||||||
|
text_data=(
|
||||||
|
f"\r\nSSH exited with status {exit_code}. "
|
||||||
|
"Verify host reachability, username, and SSH CA trust.\r\n"
|
||||||
|
)
|
||||||
|
)
|
||||||
await self.close()
|
await self.close()
|
||||||
|
|
||||||
async def _cleanup_keys_after_delay(self, delay_seconds: int):
|
async def _send_ssh_failure_diagnostics(self, exit_code: int) -> None:
|
||||||
|
lines = [
|
||||||
|
(
|
||||||
|
f"\r\nSSH exited with status {exit_code}. "
|
||||||
|
"Verify host reachability, username, and SSH CA trust.\r\n"
|
||||||
|
),
|
||||||
|
f"Target: {self.system_username}@{self.shell_target}\r\n",
|
||||||
|
]
|
||||||
|
if self.tempdir_path and _is_truthy(getattr(settings, "KEYWARDEN_SHELL_PRESERVE_TMP", False)):
|
||||||
|
lines.append(f"Session key material preserved at: {self.tempdir_path}\r\n")
|
||||||
|
cert_info = await self._inspect_session_certificate()
|
||||||
|
if cert_info:
|
||||||
|
lines.append("\r\nSession certificate details:\r\n")
|
||||||
|
lines.append(cert_info)
|
||||||
|
lines.append("\r\n")
|
||||||
|
diagnostic = await self._run_ssh_probe()
|
||||||
|
if diagnostic:
|
||||||
|
lines.append("\r\nSSH probe output:\r\n")
|
||||||
|
lines.append(diagnostic)
|
||||||
|
lines.append("\r\n")
|
||||||
|
await self.send(text_data="".join(lines))
|
||||||
|
|
||||||
|
async def _inspect_session_certificate(self) -> str:
|
||||||
|
if not self.cert_path:
|
||||||
|
return ""
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(delay_seconds)
|
proc = await asyncio.create_subprocess_exec(
|
||||||
for path in self.key_paths:
|
"ssh-keygen",
|
||||||
try:
|
"-L",
|
||||||
os.remove(path)
|
"-f",
|
||||||
except FileNotFoundError:
|
self.cert_path,
|
||||||
continue
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
except Exception:
|
stdout=asyncio.subprocess.PIPE,
|
||||||
pass
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
finally:
|
)
|
||||||
self.key_paths = []
|
except Exception:
|
||||||
|
return ""
|
||||||
|
output, _ = await proc.communicate()
|
||||||
|
if not output:
|
||||||
|
return ""
|
||||||
|
text = output.decode("utf-8", "ignore").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return _truncate_lines(text, max_lines=20, max_chars=2000)
|
||||||
|
|
||||||
|
async def _run_ssh_probe(self) -> str:
|
||||||
|
if not self.key_path or not self.cert_path or not self.system_username or not self.shell_target:
|
||||||
|
return ""
|
||||||
|
probe_command = _build_ssh_command(
|
||||||
|
key_path=self.key_path,
|
||||||
|
cert_path=self.cert_path,
|
||||||
|
username=self.system_username,
|
||||||
|
host=_format_ssh_host(self.shell_target),
|
||||||
|
remote_command="true",
|
||||||
|
force_tty=False,
|
||||||
|
log_level="DEBUG1",
|
||||||
|
connect_timeout=8,
|
||||||
|
)
|
||||||
|
probe_command.insert(1, "-v")
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*probe_command,
|
||||||
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
timeout = int(getattr(settings, "KEYWARDEN_SHELL_DIAG_TIMEOUT_SECONDS", 10))
|
||||||
|
timeout = max(1, timeout)
|
||||||
|
try:
|
||||||
|
output, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
return f"(probe timed out after {timeout}s)"
|
||||||
|
if not output:
|
||||||
|
return f"(probe exited with status {proc.returncode} and no output)"
|
||||||
|
text = output.decode("utf-8", "ignore").strip()
|
||||||
|
if not text:
|
||||||
|
return f"(probe exited with status {proc.returncode} and no output)"
|
||||||
|
return _summarize_probe_output(text)
|
||||||
|
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
def _get_server(self, user, server_id: int):
|
def _get_server(self, user, server_id: int):
|
||||||
@@ -287,7 +346,7 @@ def _generate_session_keypair(tempdir: str, user, principal: str) -> tuple[str,
|
|||||||
with open(pubkey_path, "r", encoding="utf-8") as handle:
|
with open(pubkey_path, "r", encoding="utf-8") as handle:
|
||||||
public_key = handle.read().strip()
|
public_key = handle.read().strip()
|
||||||
cert_text = _sign_public_key(
|
cert_text = _sign_public_key(
|
||||||
ca_private_key=ca.private_key,
|
ca_private_key=ca.get_private_key(),
|
||||||
ca_public_key=ca.public_key,
|
ca_public_key=ca.public_key,
|
||||||
public_key=public_key,
|
public_key=public_key,
|
||||||
identity=identity,
|
identity=identity,
|
||||||
@@ -310,3 +369,143 @@ def _format_ssh_host(host: str) -> str:
|
|||||||
if ":" in host and not (host.startswith("[") and host.endswith("]")):
|
if ":" in host and not (host.startswith("[") and host.endswith("]")):
|
||||||
return f"[{host}]"
|
return f"[{host}]"
|
||||||
return host
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ssh_command(
|
||||||
|
*,
|
||||||
|
key_path: str,
|
||||||
|
cert_path: str,
|
||||||
|
username: str,
|
||||||
|
host: str,
|
||||||
|
remote_command: str | None = None,
|
||||||
|
force_tty: bool = True,
|
||||||
|
log_level: str = "ERROR",
|
||||||
|
connect_timeout: int | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
strict_host_key_checking = str(
|
||||||
|
getattr(settings, "KEYWARDEN_SHELL_STRICT_HOST_KEY_CHECKING", "accept-new")
|
||||||
|
).strip().lower()
|
||||||
|
if strict_host_key_checking not in {"yes", "accept-new"}:
|
||||||
|
strict_host_key_checking = "accept-new"
|
||||||
|
command = [
|
||||||
|
"ssh",
|
||||||
|
]
|
||||||
|
if force_tty:
|
||||||
|
command.append("-tt")
|
||||||
|
command.extend(
|
||||||
|
[
|
||||||
|
"-i",
|
||||||
|
key_path,
|
||||||
|
"-o",
|
||||||
|
f"CertificateFile={cert_path}",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"PasswordAuthentication=no",
|
||||||
|
"-o",
|
||||||
|
"KbdInteractiveAuthentication=no",
|
||||||
|
"-o",
|
||||||
|
"ChallengeResponseAuthentication=no",
|
||||||
|
"-o",
|
||||||
|
"PreferredAuthentications=publickey",
|
||||||
|
"-o",
|
||||||
|
f"StrictHostKeyChecking={strict_host_key_checking}",
|
||||||
|
"-o",
|
||||||
|
"UpdateHostKeys=yes",
|
||||||
|
"-o",
|
||||||
|
f"LogLevel={log_level}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if connect_timeout is not None:
|
||||||
|
command.extend(["-o", f"ConnectTimeout={connect_timeout}"])
|
||||||
|
command.append(f"{username}@{host}")
|
||||||
|
if remote_command is None:
|
||||||
|
remote_command = str(getattr(settings, "KEYWARDEN_SHELL_REMOTE_COMMAND", "/bin/bash")).strip()
|
||||||
|
else:
|
||||||
|
remote_command = str(remote_command).strip()
|
||||||
|
if not remote_command:
|
||||||
|
return command
|
||||||
|
try:
|
||||||
|
remote_argv = shlex.split(remote_command)
|
||||||
|
except ValueError:
|
||||||
|
remote_argv = [remote_command]
|
||||||
|
if remote_argv:
|
||||||
|
command.extend(remote_argv)
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def _is_truthy(value) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_lines(text: str, *, max_lines: int, max_chars: int) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
clipped = text[:max_chars]
|
||||||
|
lines = clipped.splitlines()
|
||||||
|
if len(lines) > max_lines:
|
||||||
|
head_count = max(1, max_lines // 2)
|
||||||
|
tail_count = max(1, max_lines - head_count)
|
||||||
|
lines = (
|
||||||
|
lines[:head_count]
|
||||||
|
+ ["... (middle omitted) ..."]
|
||||||
|
+ lines[-tail_count:]
|
||||||
|
)
|
||||||
|
if len(text) > max_chars:
|
||||||
|
lines.append("... (truncated by size)")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_probe_output(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
lines = text.splitlines()
|
||||||
|
lower_text = text.lower()
|
||||||
|
causes = []
|
||||||
|
if "account has expired" in lower_text:
|
||||||
|
causes.append("Remote Linux account is expired.")
|
||||||
|
if "permission denied (publickey" in lower_text or "permission denied" in lower_text:
|
||||||
|
causes.append("SSH authentication was denied by server policy.")
|
||||||
|
if "no such user" in lower_text:
|
||||||
|
causes.append("Remote user account does not exist.")
|
||||||
|
if "certificate invalid" in lower_text or "invalid certificate" in lower_text:
|
||||||
|
causes.append("SSH certificate was rejected by the server.")
|
||||||
|
keywords = (
|
||||||
|
"authenticating to",
|
||||||
|
"offering public key",
|
||||||
|
"offering publickey",
|
||||||
|
"server accepts key",
|
||||||
|
"sign_and_send_pubkey",
|
||||||
|
"authentications that can continue",
|
||||||
|
"permission denied",
|
||||||
|
"authentication succeeded",
|
||||||
|
"received disconnect",
|
||||||
|
"userauth",
|
||||||
|
"certificate",
|
||||||
|
"principal",
|
||||||
|
"no such user",
|
||||||
|
"account is locked",
|
||||||
|
)
|
||||||
|
interesting = []
|
||||||
|
for line in lines:
|
||||||
|
lower = line.lower()
|
||||||
|
if any(keyword in lower for keyword in keywords):
|
||||||
|
interesting.append(line)
|
||||||
|
out = []
|
||||||
|
if causes:
|
||||||
|
out.append("Detected cause:")
|
||||||
|
for cause in causes:
|
||||||
|
out.append(f"- {cause}")
|
||||||
|
out.append("")
|
||||||
|
if interesting:
|
||||||
|
out.append("Auth highlights:")
|
||||||
|
out.extend(interesting[-30:])
|
||||||
|
if lines:
|
||||||
|
out.append("")
|
||||||
|
out.append("Probe tail:")
|
||||||
|
out.extend(lines[-20:])
|
||||||
|
return _truncate_lines("\n".join(out), max_lines=80, max_chars=8000)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("servers", "0009_server_heartbeat_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ServerAuditLog",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("event_at", models.DateTimeField(db_index=True)),
|
||||||
|
("received_at", models.DateTimeField(db_index=True, default=timezone.now, editable=False)),
|
||||||
|
("category", models.CharField(db_index=True, max_length=64)),
|
||||||
|
("event_type", models.CharField(db_index=True, max_length=128)),
|
||||||
|
("unit", models.CharField(blank=True, max_length=128)),
|
||||||
|
("priority", models.CharField(blank=True, db_index=True, max_length=16)),
|
||||||
|
("hostname", models.CharField(blank=True, max_length=253)),
|
||||||
|
("username", models.CharField(blank=True, db_index=True, max_length=150)),
|
||||||
|
("principal", models.CharField(blank=True, max_length=255)),
|
||||||
|
("source_ip", models.GenericIPAddressField(blank=True, null=True)),
|
||||||
|
("session_id", models.CharField(blank=True, max_length=128)),
|
||||||
|
("message", models.TextField(blank=True)),
|
||||||
|
("raw", models.TextField(blank=True)),
|
||||||
|
("fields", models.JSONField(blank=True, default=dict)),
|
||||||
|
(
|
||||||
|
"server",
|
||||||
|
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="audit_logs", to="servers.server"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Server audit log",
|
||||||
|
"verbose_name_plural": "Server audit logs",
|
||||||
|
"ordering": ["-event_at", "-id"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["server", "event_at"], name="servers_audit_server_event_idx"),
|
||||||
|
models.Index(fields=["server", "category", "event_at"], name="servers_audit_cat_event_idx"),
|
||||||
|
models.Index(fields=["server", "event_type", "event_at"], name="servers_audit_type_event_idx"),
|
||||||
|
models.Index(fields=["server", "username", "event_at"], name="servers_audit_user_event_idx"),
|
||||||
|
models.Index(fields=["server", "source_ip", "event_at"], name="servers_audit_ip_event_idx"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("servers", "0010_serverauditlog"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ServerLogSource",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("kind", models.CharField(choices=[("service", "Service"), ("file", "File")], db_index=True, max_length=16)),
|
||||||
|
("name", models.CharField(blank=True, max_length=128)),
|
||||||
|
("service_unit", models.CharField(blank=True, max_length=128)),
|
||||||
|
("file_path", models.CharField(blank=True, max_length=512)),
|
||||||
|
("category_override", models.CharField(blank=True, max_length=64)),
|
||||||
|
("event_type_override", models.CharField(blank=True, max_length=128)),
|
||||||
|
("enabled", models.BooleanField(db_index=True, default=True)),
|
||||||
|
("created_at", models.DateTimeField(default=timezone.now, editable=False)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"server",
|
||||||
|
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="log_sources", to="servers.server"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Server log source",
|
||||||
|
"verbose_name_plural": "Server log sources",
|
||||||
|
"ordering": ["server_id", "kind", "name", "id"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["server", "enabled", "kind"], name="srvsrc_srv_en_kind_idx"),
|
||||||
|
models.Index(fields=["server", "service_unit"], name="srvsrc_srv_svc_idx"),
|
||||||
|
models.Index(fields=["server", "file_path"], name="servers_src_server_file_idx"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("servers", "0011_serverlogsource"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="serverauditlog",
|
||||||
|
name="source_kind",
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=16),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="serverauditlog",
|
||||||
|
name="source_name",
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=512),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="serverauditlog",
|
||||||
|
index=models.Index(fields=["server", "source_kind", "event_at"], name="servers_audit_kind_event_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="serverauditlog",
|
||||||
|
index=models.Index(fields=["server", "source_name", "event_at"], name="servers_audit_name_event_idx"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("servers", "0012_serverauditlog_source_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="server",
|
||||||
|
name="agent_api_token_hash",
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=64, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("servers", "0013_server_agent_api_token_hash"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="serverlogsource",
|
||||||
|
name="exclude_matches",
|
||||||
|
field=models.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="serverlogsource",
|
||||||
|
name="include_matches",
|
||||||
|
field=models.JSONField(blank=True, default=dict),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="serverlogsource",
|
||||||
|
name="parser",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("none", "None"),
|
||||||
|
("syslog", "Syslog"),
|
||||||
|
("nginx_access", "Nginx Access"),
|
||||||
|
("json", "JSON"),
|
||||||
|
],
|
||||||
|
default="none",
|
||||||
|
max_length=32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="serverlogsource",
|
||||||
|
name="kind",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("journal", "Journal"), ("service", "Service"), ("file", "File")],
|
||||||
|
db_index=True,
|
||||||
|
max_length=16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
+106
-2
@@ -12,6 +12,8 @@ from django.core.validators import RegexValidator
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.core.secret_files import looks_like_pem, read_secret_ref, write_secret_file
|
||||||
|
|
||||||
|
|
||||||
hostname_validator = RegexValidator(
|
hostname_validator = RegexValidator(
|
||||||
regex=r"^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*\.?$",
|
regex=r"^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*\.?$",
|
||||||
@@ -28,6 +30,7 @@ class Server(models.Model):
|
|||||||
agent_enrolled_at = models.DateTimeField(null=True, blank=True)
|
agent_enrolled_at = models.DateTimeField(null=True, blank=True)
|
||||||
agent_cert_fingerprint = models.CharField(max_length=128, null=True, blank=True)
|
agent_cert_fingerprint = models.CharField(max_length=128, null=True, blank=True)
|
||||||
agent_cert_serial = models.CharField(max_length=64, null=True, blank=True)
|
agent_cert_serial = models.CharField(max_length=64, null=True, blank=True)
|
||||||
|
agent_api_token_hash = models.CharField(max_length=64, null=True, blank=True, db_index=True)
|
||||||
last_heartbeat_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
last_heartbeat_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||||
last_ping_ms = models.PositiveIntegerField(null=True, blank=True)
|
last_ping_ms = models.PositiveIntegerField(null=True, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
@@ -135,8 +138,28 @@ class AgentCertificateAuthority(models.Model):
|
|||||||
self.is_active = False
|
self.is_active = False
|
||||||
self.revoked_at = timezone.now()
|
self.revoked_at = timezone.now()
|
||||||
|
|
||||||
|
def get_key_pem(self) -> str:
|
||||||
|
try:
|
||||||
|
file_payload = read_secret_ref(self.key_pem, label="Agent CA private key")
|
||||||
|
except RuntimeError as exc:
|
||||||
|
# Recover gracefully when a secret-file reference points to a
|
||||||
|
# deleted key file (for example after volume resets).
|
||||||
|
if "file missing:" in str(exc):
|
||||||
|
file_payload = ""
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
if file_payload:
|
||||||
|
return file_payload
|
||||||
|
inline_payload = (self.key_pem or "").strip()
|
||||||
|
if looks_like_pem(inline_payload):
|
||||||
|
self.key_pem = write_secret_file("agent-ca-key", inline_payload)
|
||||||
|
if self.pk:
|
||||||
|
self.save(update_fields=["key_pem"])
|
||||||
|
return inline_payload
|
||||||
|
return ""
|
||||||
|
|
||||||
def ensure_material(self) -> None:
|
def ensure_material(self) -> None:
|
||||||
if self.cert_pem and self.key_pem:
|
if self.cert_pem and self.get_key_pem():
|
||||||
return
|
return
|
||||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, self.name)])
|
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, self.name)])
|
||||||
@@ -159,7 +182,7 @@ class AgentCertificateAuthority(models.Model):
|
|||||||
encryption_algorithm=serialization.NoEncryption(),
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
).decode("utf-8")
|
).decode("utf-8")
|
||||||
self.cert_pem = cert_pem
|
self.cert_pem = cert_pem
|
||||||
self.key_pem = key_pem
|
self.key_pem = write_secret_file("agent-ca-key", key_pem)
|
||||||
self.fingerprint = cert.fingerprint(hashes.SHA256()).hex()
|
self.fingerprint = cert.fingerprint(hashes.SHA256()).hex()
|
||||||
self.serial = format(cert.serial_number, "x")
|
self.serial = format(cert.serial_number, "x")
|
||||||
|
|
||||||
@@ -189,3 +212,84 @@ class ServerAccount(models.Model):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.system_username} ({self.server_id})"
|
return f"{self.system_username} ({self.server_id})"
|
||||||
|
|
||||||
|
|
||||||
|
class ServerAuditLog(models.Model):
|
||||||
|
server = models.ForeignKey(Server, on_delete=models.CASCADE, related_name="audit_logs")
|
||||||
|
event_at = models.DateTimeField(db_index=True)
|
||||||
|
received_at = models.DateTimeField(default=timezone.now, editable=False, db_index=True)
|
||||||
|
category = models.CharField(max_length=64, db_index=True)
|
||||||
|
event_type = models.CharField(max_length=128, db_index=True)
|
||||||
|
source_kind = models.CharField(max_length=16, blank=True, db_index=True)
|
||||||
|
source_name = models.CharField(max_length=512, blank=True, db_index=True)
|
||||||
|
unit = models.CharField(max_length=128, blank=True)
|
||||||
|
priority = models.CharField(max_length=16, blank=True, db_index=True)
|
||||||
|
hostname = models.CharField(max_length=253, blank=True)
|
||||||
|
username = models.CharField(max_length=150, blank=True, db_index=True)
|
||||||
|
principal = models.CharField(max_length=255, blank=True)
|
||||||
|
source_ip = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
session_id = models.CharField(max_length=128, blank=True)
|
||||||
|
message = models.TextField(blank=True)
|
||||||
|
raw = models.TextField(blank=True)
|
||||||
|
fields = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Server audit log"
|
||||||
|
verbose_name_plural = "Server audit logs"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["server", "event_at"], name="servers_audit_server_event_idx"),
|
||||||
|
models.Index(fields=["server", "category", "event_at"], name="servers_audit_cat_event_idx"),
|
||||||
|
models.Index(fields=["server", "event_type", "event_at"], name="servers_audit_type_event_idx"),
|
||||||
|
models.Index(fields=["server", "source_kind", "event_at"], name="servers_audit_kind_event_idx"),
|
||||||
|
models.Index(fields=["server", "source_name", "event_at"], name="servers_audit_name_event_idx"),
|
||||||
|
models.Index(fields=["server", "username", "event_at"], name="servers_audit_user_event_idx"),
|
||||||
|
models.Index(fields=["server", "source_ip", "event_at"], name="servers_audit_ip_event_idx"),
|
||||||
|
]
|
||||||
|
ordering = ["-event_at", "-id"]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.server_id}:{self.category}/{self.event_type}@{self.event_at.isoformat()}"
|
||||||
|
|
||||||
|
|
||||||
|
class ServerLogSource(models.Model):
|
||||||
|
class Kind(models.TextChoices):
|
||||||
|
JOURNAL = "journal", "Journal"
|
||||||
|
SERVICE = "service", "Service"
|
||||||
|
FILE = "file", "File"
|
||||||
|
|
||||||
|
class Parser(models.TextChoices):
|
||||||
|
NONE = "none", "None"
|
||||||
|
SYSLOG = "syslog", "Syslog"
|
||||||
|
NGINX_ACCESS = "nginx_access", "Nginx Access"
|
||||||
|
JSON = "json", "JSON"
|
||||||
|
|
||||||
|
server = models.ForeignKey(Server, on_delete=models.CASCADE, related_name="log_sources")
|
||||||
|
kind = models.CharField(max_length=16, choices=Kind.choices, db_index=True)
|
||||||
|
name = models.CharField(max_length=128, blank=True)
|
||||||
|
service_unit = models.CharField(max_length=128, blank=True)
|
||||||
|
file_path = models.CharField(max_length=512, blank=True)
|
||||||
|
parser = models.CharField(max_length=32, choices=Parser.choices, default=Parser.NONE)
|
||||||
|
include_matches = models.JSONField(default=dict, blank=True)
|
||||||
|
exclude_matches = models.JSONField(default=dict, blank=True)
|
||||||
|
category_override = models.CharField(max_length=64, blank=True)
|
||||||
|
event_type_override = models.CharField(max_length=128, blank=True)
|
||||||
|
enabled = models.BooleanField(default=True, db_index=True)
|
||||||
|
created_at = models.DateTimeField(default=timezone.now, editable=False)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Server log source"
|
||||||
|
verbose_name_plural = "Server log sources"
|
||||||
|
ordering = ["server_id", "kind", "name", "id"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["server", "enabled", "kind"], name="srvsrc_srv_en_kind_idx"),
|
||||||
|
models.Index(fields=["server", "service_unit"], name="srvsrc_srv_svc_idx"),
|
||||||
|
models.Index(fields=["server", "file_path"], name="servers_src_server_file_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if self.kind == self.Kind.SERVICE:
|
||||||
|
return self.name or self.service_unit or f"service:{self.id}"
|
||||||
|
if self.kind == self.Kind.JOURNAL:
|
||||||
|
return self.name or f"journal:{self.id}"
|
||||||
|
return self.name or self.file_path or f"file:{self.id}"
|
||||||
@@ -6,18 +6,73 @@ from django.utils import timezone
|
|||||||
from apps.access.models import AccessRequest
|
from apps.access.models import AccessRequest
|
||||||
|
|
||||||
|
|
||||||
def user_can_shell(user, server, now=None) -> bool:
|
def _active_access_requests(user, server, now):
|
||||||
if user.has_perm("servers.shell_server", server):
|
return AccessRequest.objects.filter(
|
||||||
return True
|
requester=user,
|
||||||
|
server=server,
|
||||||
|
status=AccessRequest.Status.APPROVED,
|
||||||
|
).filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin_override(user) -> bool:
|
||||||
|
if not user or not getattr(user, "is_authenticated", False):
|
||||||
|
return False
|
||||||
|
return user.is_superuser or user.has_perm("servers.change_server")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_manual_view_access(user, server, now) -> bool:
|
||||||
|
if not user.has_perm("servers.view_server", server):
|
||||||
|
return False
|
||||||
|
# If a user has active access requests on this server, scope flags govern
|
||||||
|
# capability checks. Otherwise, object/global server grants continue to work.
|
||||||
|
return not _active_access_requests(user, server, now).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_any_access(user, server, now=None) -> bool:
|
||||||
if now is None:
|
if now is None:
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
if _is_admin_override(user) or _has_manual_view_access(user, server, now):
|
||||||
|
return True
|
||||||
return (
|
return (
|
||||||
AccessRequest.objects.filter(
|
_active_access_requests(user, server, now)
|
||||||
requester=user,
|
.filter(Q(request_shell=True) | Q(request_logs=True) | Q(request_users=True))
|
||||||
server=server,
|
.exists()
|
||||||
status=AccessRequest.Status.APPROVED,
|
)
|
||||||
request_shell=True,
|
|
||||||
)
|
|
||||||
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
|
def user_can_shell(user, server, now=None) -> bool:
|
||||||
|
if now is None:
|
||||||
|
now = timezone.now()
|
||||||
|
if user.has_perm("servers.shell_server", server):
|
||||||
|
return True
|
||||||
|
if _is_admin_override(user):
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
_active_access_requests(user, server, now)
|
||||||
|
.filter(request_shell=True)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def user_can_logs(user, server, now=None) -> bool:
|
||||||
|
if now is None:
|
||||||
|
now = timezone.now()
|
||||||
|
if _is_admin_override(user) or _has_manual_view_access(user, server, now):
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
_active_access_requests(user, server, now)
|
||||||
|
.filter(request_logs=True)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def user_can_users(user, server, now=None) -> bool:
|
||||||
|
if now is None:
|
||||||
|
now = timezone.now()
|
||||||
|
if _is_admin_override(user) or _has_manual_view_access(user, server, now):
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
_active_access_requests(user, server, now)
|
||||||
|
.filter(request_users=True)
|
||||||
.exists()
|
.exists()
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
{% load tz %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<details class="rounded-xl border border-gray-200 bg-gray-50" {% if panel.filters_expanded %}open{% endif %}>
|
||||||
|
<summary class="flex cursor-pointer items-center justify-between px-4 py-3 text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||||
|
<span>Filters</span>
|
||||||
|
<span class="rounded-full bg-white px-2 py-0.5 text-[11px] font-semibold text-gray-700">Matches: {{ panel.filtered_count }}</span>
|
||||||
|
</summary>
|
||||||
|
<div class="border-t border-gray-200 bg-white p-4">
|
||||||
|
<form method="get" action="{{ panel.panel_action }}" class="js-audit-panel-form grid gap-3 md:grid-cols-4">
|
||||||
|
<input type="hidden" name="line_limit" value="{{ panel.filter_values.line_limit }}" />
|
||||||
|
<input type="hidden" name="login_outcome" value="{{ panel.filter_values.login_outcome }}" />
|
||||||
|
<input type="hidden" name="http_status" value="{{ panel.filter_values.http_status }}" />
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Source type</label>
|
||||||
|
<select name="source_kind" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for option in panel.filter_options.source_kinds %}
|
||||||
|
{% if option %}
|
||||||
|
<option value="{{ option }}" {% if panel.filter_values.source_kind == option %}selected{% endif %}>{{ option }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Source</label>
|
||||||
|
<select name="source_name" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for option in panel.filter_options.source_names %}
|
||||||
|
{% if option %}
|
||||||
|
<option value="{{ option }}" {% if panel.filter_values.source_name == option %}selected{% endif %}>{{ option }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Category</label>
|
||||||
|
<select name="category" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for option in panel.filter_options.categories %}
|
||||||
|
<option value="{{ option }}" {% if panel.filter_values.category == option %}selected{% endif %}>{{ option }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Event type</label>
|
||||||
|
<select name="event_type" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for option in panel.filter_options.event_types %}
|
||||||
|
<option value="{{ option }}" {% if panel.filter_values.event_type == option %}selected{% endif %}>{{ option }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Priority</label>
|
||||||
|
<select name="priority" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700">
|
||||||
|
<option value="">All</option>
|
||||||
|
{% for option in panel.filter_options.priorities %}
|
||||||
|
{% if option %}
|
||||||
|
<option value="{{ option }}" {% if panel.filter_values.priority == option %}selected{% endif %}>{{ option }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Username</label>
|
||||||
|
<input name="username" type="text" value="{{ panel.filter_values.username }}" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Source IP</label>
|
||||||
|
<input name="source_ip" type="text" value="{{ panel.filter_values.source_ip }}" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Search</label>
|
||||||
|
<input name="q" type="text" value="{{ panel.filter_values.q }}" placeholder="message, principal, source" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Since</label>
|
||||||
|
<input name="since" type="datetime-local" value="{{ panel.filter_values.since }}" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-gray-500">Until</label>
|
||||||
|
<input name="until" type="datetime-local" value="{{ panel.filter_values.until }}" class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-4 flex items-center justify-end gap-2">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">Apply</button>
|
||||||
|
<a href="{{ panel.reset_href }}" class="js-panel-link inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50">Reset</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{% if panel.logs %}
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-gray-200">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-xs">
|
||||||
|
<thead class="bg-gray-50 text-left uppercase tracking-wide text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 font-semibold">Time</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Source</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Log line</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 text-gray-700">
|
||||||
|
{% for log in panel.logs %}
|
||||||
|
<tr class="align-top">
|
||||||
|
<td class="whitespace-nowrap px-3 py-2 text-gray-500">{{ log.event_at|utc|date:"Y-m-d H:i:s" }} UTC</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<div class="font-medium text-gray-900">{{ log.compact_source }}</div>
|
||||||
|
<div class="text-gray-500">{{ log.event_type }} • {{ log.category }}{% if log.source_kind %} • {{ log.source_kind }}{% endif %}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<div class="font-mono text-[11px] text-gray-900">{{ log.compact_line }}</div>
|
||||||
|
{% if log.username or log.source_ip or log.session_id %}
|
||||||
|
<div class="mt-1 text-gray-500">
|
||||||
|
{% if log.username %}user={{ log.username }}{% endif %}
|
||||||
|
{% if log.source_ip %}{% if log.username %} • {% endif %}ip={{ log.source_ip }}{% endif %}
|
||||||
|
{% if log.session_id %}{% if log.username or log.source_ip %} • {% endif %}session={{ log.session_id }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-xs text-gray-600">
|
||||||
|
<span>Page {{ panel.page_obj.number }} of {{ panel.page_obj.paginator.num_pages }}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if panel.page_obj.has_previous %}
|
||||||
|
<a href="{{ panel.panel_action }}?{% if panel.query_without_page %}{{ panel.query_without_page }}&{% endif %}page={{ panel.page_obj.previous_page_number }}" class="js-panel-link rounded border border-gray-200 px-2.5 py-1 hover:bg-gray-50">Previous</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if panel.page_obj.has_next %}
|
||||||
|
<a href="{{ panel.panel_action }}?{% if panel.query_without_page %}{{ panel.query_without_page }}&{% endif %}page={{ panel.page_obj.next_page_number }}" class="js-panel-link rounded border border-gray-200 px-2.5 py-1 hover:bg-gray-50">Next</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900">No logs for this panel filter</h4>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Adjust source/file/service filters or broaden the timeframe.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -85,18 +85,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="mt-4 flex flex-wrap gap-2 border-b border-gray-200 pb-3 text-sm font-medium text-gray-500">
|
<nav class="mt-4 flex flex-wrap gap-2 border-b border-gray-200 pb-3 text-sm font-medium text-gray-500">
|
||||||
<a
|
{% if can_users %}
|
||||||
href="{% url 'servers:detail' server.id %}"
|
<a
|
||||||
class="{% if active_tab == 'details' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
|
href="{% url 'servers:detail' server.id %}"
|
||||||
>
|
class="{% if active_tab == 'details' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
|
||||||
Details
|
>
|
||||||
</a>
|
Details
|
||||||
<a
|
</a>
|
||||||
href="{% url 'servers:audit' server.id %}"
|
{% endif %}
|
||||||
class="{% if active_tab == 'audit' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
|
{% if can_logs %}
|
||||||
>
|
<a
|
||||||
Audit
|
href="{% url 'servers:audit' server.id %}"
|
||||||
</a>
|
class="{% if active_tab == 'audit' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
|
||||||
|
>
|
||||||
|
Audit
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% if can_shell %}
|
{% if can_shell %}
|
||||||
<a
|
<a
|
||||||
href="{% url 'servers:shell' server.id %}"
|
href="{% url 'servers:shell' server.id %}"
|
||||||
@@ -105,10 +109,20 @@
|
|||||||
Shell
|
Shell
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a
|
{% if can_users %}
|
||||||
href="{% url 'servers:settings' server.id %}"
|
<a
|
||||||
class="{% if active_tab == 'settings' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
|
href="{% url 'servers:settings' server.id %}"
|
||||||
>
|
class="{% if active_tab == 'settings' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
|
||||||
Settings
|
>
|
||||||
</a>
|
Settings
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_server_admin %}
|
||||||
|
<a
|
||||||
|
href="{% url 'servers:server_admin' server.id %}"
|
||||||
|
class="{% if active_tab == 'admin' %}rounded-full bg-blue-50 px-4 py-1.5 text-blue-700 ring-1 ring-blue-100{% else %}rounded-full bg-gray-100 px-4 py-1.5 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Admin • Keywarden{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Admin</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Global administrative controls for Keywarden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<a
|
||||||
|
href="{% url 'servers:admin_dashboard' %}?view=users"
|
||||||
|
class="rounded-2xl border p-5 shadow-sm transition hover:border-blue-300 hover:bg-blue-50 {% if active_view == 'users' %}border-blue-300 bg-blue-50{% else %}border-gray-200 bg-white{% endif %}"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Users</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-gray-900">{{ summary.users }}</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="{% url 'servers:admin_dashboard' %}?view=servers"
|
||||||
|
class="rounded-2xl border p-5 shadow-sm transition hover:border-blue-300 hover:bg-blue-50 {% if active_view == 'servers' %}border-blue-300 bg-blue-50{% else %}border-gray-200 bg-white{% endif %}"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Servers</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-gray-900">{{ summary.servers }}</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="{% url 'servers:admin_dashboard' %}?view=pending"
|
||||||
|
class="rounded-2xl border p-5 shadow-sm transition hover:border-amber-300 hover:bg-amber-50 {% if active_view == 'pending' %}border-amber-300 bg-amber-50{% else %}border-gray-200 bg-white{% endif %}"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Pending Requests</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-amber-700">{{ summary.pending_requests }}</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="{% url 'servers:admin_dashboard' %}?view=grants"
|
||||||
|
class="rounded-2xl border p-5 shadow-sm transition hover:border-emerald-300 hover:bg-emerald-50 {% if active_view == 'grants' %}border-emerald-300 bg-emerald-50{% else %}border-gray-200 bg-white{% endif %}"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">Active Grants</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-emerald-700">{{ summary.active_grants }}</p>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if active_view == "users" %}
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between gap-3 border-b border-gray-100 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Users</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Manage users, review logins, and jump to user-filtered grants.</p>
|
||||||
|
</div>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-1 text-xs font-semibold text-blue-800">
|
||||||
|
{{ users|length }} users
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if users %}
|
||||||
|
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-sm" data-sort-table>
|
||||||
|
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="text">User <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="date">Last login <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="number">Grants applied <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 text-gray-700">
|
||||||
|
{% for row in users %}
|
||||||
|
<tr class="{% if row.has_pending_erasure_request %}bg-amber-50{% endif %}">
|
||||||
|
<td class="px-3 py-2" data-sort-value="{{ row.user.username|lower }}">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-gray-900">{{ row.user.username }}</span>
|
||||||
|
{% if row.has_pending_erasure_request %}
|
||||||
|
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-800">GDPR request</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2" data-sort-value="{% if row.user.last_login %}{{ row.user.last_login|date:'YmdHis' }}{% else %}0{% endif %}">
|
||||||
|
{% if row.user.last_login %}
|
||||||
|
{{ row.user.last_login|date:"M j, Y H:i" }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-500">Never</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2" data-sort-value="{{ row.active_grants_count }}">
|
||||||
|
<a
|
||||||
|
href="{% url 'servers:admin_dashboard' %}?view=grants&grant_user={{ row.user.id }}"
|
||||||
|
class="font-medium text-blue-700 hover:underline"
|
||||||
|
title="{{ row.active_grants_tooltip }}"
|
||||||
|
>
|
||||||
|
{{ row.active_grants_count }} active
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-right">
|
||||||
|
<div class="inline-flex flex-wrap justify-end gap-2">
|
||||||
|
<form method="post" action="{% url 'servers:admin_send_password_reset' row.user.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">Send reset email</button>
|
||||||
|
</form>
|
||||||
|
{% if row.user.id != request.user.id %}
|
||||||
|
<form method="post" action="{% url 'servers:admin_delete_user' row.user.id %}" onsubmit="return window.confirm('Delete user {{ row.user.username|escapejs }}? This cannot be undone.');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center rounded-lg px-3 py-1.5 text-xs font-semibold text-white {% if row.has_pending_erasure_request %}bg-amber-600 hover:bg-amber-700{% else %}bg-rose-600 hover:bg-rose-700{% endif %}"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-500">Current user</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-4 text-sm text-gray-500">No users found.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% elif active_view == "servers" %}
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between gap-3 border-b border-gray-100 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Servers</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Manage registered servers and remove server records.</p>
|
||||||
|
</div>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-1 text-xs font-semibold text-blue-800">
|
||||||
|
{{ servers|length }} servers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if servers %}
|
||||||
|
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-sm" data-sort-table>
|
||||||
|
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="text">Server <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="text">Hostname / IP <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="date">Agent enrolled <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="date">Last heartbeat <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 text-gray-700">
|
||||||
|
{% for server in servers %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2" data-sort-value="{{ server.display_name|lower }}">
|
||||||
|
<a href="{% url 'servers:server_admin' server.id %}" class="font-medium text-blue-700 hover:underline">{{ server.display_name }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2" data-sort-value="{{ server.hostname|default:server.ipv4|default:server.ipv6|default:'unassigned'|lower }}">{{ server.hostname|default:server.ipv4|default:server.ipv6|default:"Unassigned" }}</td>
|
||||||
|
<td class="px-3 py-2" data-sort-value="{% if server.agent_enrolled_at %}{{ server.agent_enrolled_at|date:'YmdHis' }}{% else %}0{% endif %}">
|
||||||
|
{% if server.agent_enrolled_at %}
|
||||||
|
{{ server.agent_enrolled_at|date:"M j, Y H:i" }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-500">Not enrolled</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2" data-sort-value="{% if server.last_heartbeat_at %}{{ server.last_heartbeat_at|date:'YmdHis' }}{% else %}0{% endif %}">
|
||||||
|
{% if server.last_heartbeat_at %}
|
||||||
|
{{ server.last_heartbeat_at|date:"M j, Y H:i" }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-500">Never</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-right">
|
||||||
|
<form method="post" action="{% url 'servers:admin_delete_server' server.id %}" onsubmit="return window.confirm('Delete server {{ server.display_name|escapejs }} and related records?');" class="inline-flex">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-4 text-sm text-gray-500">No servers available.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% elif active_view == "grants" %}
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-gray-100 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Active grants</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Review active grants, revoke access, and update expiry.</p>
|
||||||
|
</div>
|
||||||
|
<form method="get" action="{% url 'servers:admin_dashboard' %}" class="flex items-center gap-2">
|
||||||
|
<input type="hidden" name="view" value="grants">
|
||||||
|
<label for="grant-user-filter" class="text-xs font-semibold uppercase tracking-wide text-gray-500">Filter by user</label>
|
||||||
|
<select id="grant-user-filter" name="grant_user" class="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700">
|
||||||
|
<option value="">All users</option>
|
||||||
|
{% for user in grant_filter_users %}
|
||||||
|
<option value="{{ user.id }}" {% if grant_user_filter_id == user.id %}selected{% endif %}>{{ user.username }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">Apply</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% if selected_grant_user %}
|
||||||
|
<p class="mt-3 text-xs text-gray-500">Filtered for user: <span class="font-semibold text-gray-700">{{ selected_grant_user.username }}</span></p>
|
||||||
|
{% endif %}
|
||||||
|
{% if grant_rows %}
|
||||||
|
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-sm" data-sort-table>
|
||||||
|
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="text">User <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="text">Server <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="date">Last use <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="date">Expiry <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 text-gray-700">
|
||||||
|
{% for row in grant_rows %}
|
||||||
|
<tr class="{% if row.expires_soon %}bg-amber-50{% endif %}">
|
||||||
|
<td class="px-3 py-2" data-sort-value="{{ row.grant.requester.username|lower }}">{{ row.grant.requester.username }}</td>
|
||||||
|
<td class="px-3 py-2" data-sort-value="{{ row.grant.server.display_name|lower }}">{{ row.grant.server.display_name }}</td>
|
||||||
|
<td class="px-3 py-2" data-sort-value="{% if row.last_used_at %}{{ row.last_used_at|date:'YmdHis' }}{% else %}0{% endif %}">
|
||||||
|
{% if row.last_used_at %}
|
||||||
|
{{ row.last_used_at|date:"M j, Y H:i" }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-500">No activity</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2" data-sort-value="{% if row.grant.expires_at %}{{ row.grant.expires_at|date:'YmdHis' }}{% else %}99999999999999{% endif %}">
|
||||||
|
{% if row.grant.expires_at %}
|
||||||
|
{{ row.grant.expires_at|date:"M j, Y H:i" }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-500">No expiry</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-right">
|
||||||
|
<div class="inline-flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<form method="post" action="{% url 'servers:admin_revoke_grant' row.grant.id %}" onsubmit="return window.confirm('Revoke grant for {{ row.grant.requester.username|escapejs }} on {{ row.grant.server.display_name|escapejs }}?');">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700">Revoke</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'servers:admin_change_grant_expiry' row.grant.id %}" class="inline-flex items-center gap-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="expires_at"
|
||||||
|
value="{{ row.expires_at_input }}"
|
||||||
|
class="rounded-lg border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700"
|
||||||
|
>
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">Change expiry</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-4 text-sm text-gray-500">No active grants available for this filter.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between gap-3 border-b border-gray-100 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Access request queue</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Review and decide pending access requests.</p>
|
||||||
|
</div>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">
|
||||||
|
{{ pending_access_requests|length }} pending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if pending_access_requests %}
|
||||||
|
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-sm" data-sort-table>
|
||||||
|
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="text">Requester <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="text">Server <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="number">Requested scopes <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold" data-sortable="true" data-sort-type="date">Requested at <span class="ml-1" data-sort-indicator></span></th>
|
||||||
|
<th class="px-3 py-2 font-semibold text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 text-gray-700">
|
||||||
|
{% for row in pending_access_requests %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2" data-sort-value="{{ row.request.requester.username|lower }}">{{ row.request.requester.username }}</td>
|
||||||
|
<td class="px-3 py-2" data-sort-value="{{ row.request.server.display_name|lower }}">
|
||||||
|
<a href="{% url 'servers:server_admin' row.request.server.id %}" class="font-medium text-blue-700 hover:underline">
|
||||||
|
{{ row.request.server.display_name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2" data-sort-value="{{ row.scope_count }}">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% if row.request.request_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
|
||||||
|
{% if row.request.request_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
|
||||||
|
{% if row.request.request_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
|
||||||
|
{% if row.request.requested_duration_hours %}<span class="inline-flex rounded-full bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700">{{ row.request.requested_duration_hours }}h</span>{% endif %}
|
||||||
|
{% if row.request.requested_server_username %}<span class="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs font-semibold text-gray-700">@{{ row.request.requested_server_username }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2" data-sort-value="{{ row.request.requested_at|date:'YmdHis' }}">{{ row.request.requested_at|date:"M j, Y H:i" }}</td>
|
||||||
|
<td class="px-3 py-2 text-right">
|
||||||
|
<div class="inline-flex gap-2">
|
||||||
|
<form method="post" action="{% url 'servers:decide_access_request' row.request.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="approve">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Approve</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'servers:decide_access_request' row.request.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="deny">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700">Deny</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-4 text-sm text-gray-500">No pending access requests.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div class="border-b border-gray-100 pb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Global admin utilities</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Central administrative tooling for Keywarden operations.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{% for utility in utilities %}
|
||||||
|
<a href="{{ utility.url }}" class="rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:border-blue-200 hover:bg-blue-50">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900">{{ utility.title }}</h3>
|
||||||
|
<p class="mt-1 text-xs text-gray-600">{{ utility.description }}</p>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var tables = document.querySelectorAll("[data-sort-table]");
|
||||||
|
|
||||||
|
function getCellValue(row, index) {
|
||||||
|
var cell = row.children[index];
|
||||||
|
if (!cell) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
var explicit = cell.getAttribute("data-sort-value");
|
||||||
|
if (explicit !== null) {
|
||||||
|
return explicit;
|
||||||
|
}
|
||||||
|
return (cell.textContent || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValue(value, type) {
|
||||||
|
if (type === "number") {
|
||||||
|
var n = Number(value);
|
||||||
|
return Number.isNaN(n) ? 0 : n;
|
||||||
|
}
|
||||||
|
if (type === "date") {
|
||||||
|
var d = Number(value);
|
||||||
|
return Number.isNaN(d) ? 0 : d;
|
||||||
|
}
|
||||||
|
return String(value || "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
tables.forEach(function (table) {
|
||||||
|
var body = table.querySelector("tbody");
|
||||||
|
if (!body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var headers = Array.prototype.slice.call(table.querySelectorAll("th[data-sortable='true']"));
|
||||||
|
headers.forEach(function (header) {
|
||||||
|
header.classList.add("cursor-pointer", "select-none");
|
||||||
|
header.dataset.sortDirection = "none";
|
||||||
|
header.addEventListener("click", function () {
|
||||||
|
var allHeaderCells = Array.prototype.slice.call(header.parentElement.children);
|
||||||
|
var columnIndex = allHeaderCells.indexOf(header);
|
||||||
|
if (columnIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var direction = header.dataset.sortDirection === "asc" ? "desc" : "asc";
|
||||||
|
headers.forEach(function (th) {
|
||||||
|
if (th !== header) {
|
||||||
|
th.dataset.sortDirection = "none";
|
||||||
|
var indicator = th.querySelector("[data-sort-indicator]");
|
||||||
|
if (indicator) {
|
||||||
|
indicator.textContent = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
header.dataset.sortDirection = direction;
|
||||||
|
var headerIndicator = header.querySelector("[data-sort-indicator]");
|
||||||
|
if (headerIndicator) {
|
||||||
|
headerIndicator.textContent = direction === "asc" ? "▲" : "▼";
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = Array.prototype.slice.call(body.querySelectorAll("tr"));
|
||||||
|
var sortType = header.getAttribute("data-sort-type") || "text";
|
||||||
|
rows.sort(function (a, b) {
|
||||||
|
var aVal = parseValue(getCellValue(a, columnIndex), sortType);
|
||||||
|
var bVal = parseValue(getCellValue(b, columnIndex), sortType);
|
||||||
|
if (aVal < bVal) {
|
||||||
|
return direction === "asc" ? -1 : 1;
|
||||||
|
}
|
||||||
|
if (aVal > bVal) {
|
||||||
|
return direction === "asc" ? 1 : -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
body.appendChild(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,46 +1,664 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% load static tz %}
|
||||||
|
|
||||||
{% block title %}Audit • {{ server.display_name }} • Keywarden{% endblock %}
|
{% block title %}Audit • {{ server.display_name }} • Keywarden{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.ak-chart-card {
|
||||||
|
border-color: var(--kw-chart-card-border);
|
||||||
|
background: var(--kw-chart-card-bg);
|
||||||
|
color: var(--kw-chart-text);
|
||||||
|
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ak-chart-title {
|
||||||
|
color: var(--kw-chart-text);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ak-chart-subtitle {
|
||||||
|
color: var(--kw-chart-subtext);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ak-chart-badge {
|
||||||
|
border: 1px solid var(--kw-chart-badge-border);
|
||||||
|
background: var(--kw-chart-badge-bg);
|
||||||
|
color: var(--kw-chart-badge-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ak-chart-canvas-wrap {
|
||||||
|
border: 1px solid var(--kw-chart-canvas-border);
|
||||||
|
background: var(--kw-chart-canvas-bg);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ak-chart-empty {
|
||||||
|
color: var(--kw-chart-empty-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
{% include "servers/_header.html" %}
|
{% include "servers/_header.html" %}
|
||||||
|
|
||||||
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
<section class="rounded-2xl border border-gray-200 bg-white p-3 shadow-sm">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex flex-wrap items-center gap-2 text-sm font-medium text-gray-500">
|
||||||
<div>
|
<button
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Audit logs</h2>
|
type="button"
|
||||||
<p class="mt-1 text-sm text-gray-500">Track certificate issuance and access events.</p>
|
class="js-audit-subpage rounded-full px-4 py-1.5 text-xs font-semibold {% if audit_subpage == 'charts' %}bg-blue-50 text-blue-700 ring-1 ring-blue-100{% else %}bg-gray-100 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
|
||||||
</div>
|
data-audit-subpage="charts"
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
|
>
|
||||||
</div>
|
Charts
|
||||||
<div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
|
</button>
|
||||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
|
<button
|
||||||
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
type="button"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" />
|
class="js-audit-subpage rounded-full px-4 py-1.5 text-xs font-semibold {% if audit_subpage == 'logs' %}bg-blue-50 text-blue-700 ring-1 ring-blue-100{% else %}bg-gray-100 text-gray-600 hover:bg-white hover:text-gray-900{% endif %}"
|
||||||
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
|
data-audit-subpage="logs"
|
||||||
</svg>
|
>
|
||||||
</div>
|
Logs
|
||||||
<p class="mt-3 text-sm text-gray-600">Logs will appear here once collection is enabled for this server.</p>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Metrics</h2>
|
<h2 class="text-lg font-semibold text-gray-900">Audit workspace</h2>
|
||||||
<p class="mt-1 text-sm text-gray-500">Monitor CPU, memory, and session activity.</p>
|
<p class="mt-1 text-sm text-gray-500">Open multiple filtered panels side by side for service/file comparisons.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-xs font-semibold">
|
||||||
|
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 text-blue-700">Total: {{ audit_summary.total_count }}</span>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-emerald-700">24h: {{ audit_summary.recent_24h_count }}</span>
|
||||||
|
<div id="js-audit-log-controls" class="flex flex-wrap items-center gap-2 {% if audit_subpage != 'logs' %}hidden{% endif %}">
|
||||||
|
<label for="js-audit-line-limit" class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2.5 py-1 text-gray-700">
|
||||||
|
<span>Lines</span>
|
||||||
|
<select id="js-audit-line-limit" class="rounded border border-gray-200 bg-white px-2 py-0.5 text-xs font-semibold text-gray-700">
|
||||||
|
{% for option in audit_line_limit_options %}
|
||||||
|
<option value="{{ option }}" {% if option == audit_line_limit %}selected{% endif %}>{{ option }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" id="js-add-audit-panel" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">
|
||||||
|
New panel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
|
<dl class="mt-4 grid gap-4 text-xs text-gray-600 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
|
<div>
|
||||||
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<dt class="font-semibold uppercase tracking-wide text-gray-500">Last event</dt>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 12h18M7 8v8M17 8v8" />
|
<dd class="mt-1 text-sm font-semibold text-gray-900">{{ audit_summary.last_event_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if audit_summary.last_event_at %} UTC{% endif %}</dd>
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-sm text-gray-600">Metrics will appear here once collection is enabled for this server.</p>
|
<div>
|
||||||
|
<dt class="font-semibold uppercase tracking-wide text-gray-500">Last ingest</dt>
|
||||||
|
<dd class="mt-1 text-sm font-semibold text-gray-900">{{ audit_summary.last_ingest_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if audit_summary.last_ingest_at %} UTC{% endif %}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<dt class="font-semibold uppercase tracking-wide text-gray-500">Top categories</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
{% if audit_summary.top_categories %}
|
||||||
|
{% for item in audit_summary.top_categories %}
|
||||||
|
<span class="mr-2 inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-700">{{ item.category }}: {{ item.total }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
—
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="js-audit-subpage-charts" class="space-y-4 {% if audit_subpage != 'charts' %}hidden{% endif %}">
|
||||||
|
{% with login=audit_charts.login_attempts %}
|
||||||
|
<article class="ak-chart-card rounded-2xl border p-5 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="ak-chart-title text-sm">Login Attempts vs Failures</h3>
|
||||||
|
<p class="ak-chart-subtitle mt-1 text-xs">Click a bar to open logs with matching filters.</p>
|
||||||
|
</div>
|
||||||
|
<span class="ak-chart-badge inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold">
|
||||||
|
Failure rate: {{ login.failure_rate_pct }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ak-chart-canvas-wrap mt-4 h-64">
|
||||||
|
<canvas id="js-login-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<p id="js-login-chart-empty" class="ak-chart-empty mt-2 text-xs {% if login.chart.labels %}hidden{% endif %}">No login events detected yet.</p>
|
||||||
|
</article>
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
{% with http_codes=audit_charts.http_status_codes %}
|
||||||
|
<article class="ak-chart-card rounded-2xl border p-5 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="ak-chart-title text-sm">HTTP Status Codes (Nginx)</h3>
|
||||||
|
<p class="ak-chart-subtitle mt-1 text-xs">Excludes implicit `1xx` and `200`; click a bar to filter logs.</p>
|
||||||
|
</div>
|
||||||
|
<span class="ak-chart-badge inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold">
|
||||||
|
Visible HTTP events: {{ http_codes.total }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ak-chart-canvas-wrap mt-4 h-64">
|
||||||
|
<canvas id="js-http-status-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
{% if http_codes.chart.labels %}
|
||||||
|
<p id="js-http-status-chart-empty" class="ak-chart-empty mt-2 hidden text-xs"></p>
|
||||||
|
{% elif http_codes.implicit_total %}
|
||||||
|
<p id="js-http-status-chart-empty" class="ak-chart-empty mt-2 text-xs">Only implicit `1xx`/`200` responses were observed ({{ http_codes.implicit_total }} entries), so this chart is intentionally empty.</p>
|
||||||
|
{% else %}
|
||||||
|
<p id="js-http-status-chart-empty" class="ak-chart-empty mt-2 text-xs">No HTTP access events with status codes yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% with event_types=audit_charts.security_event_types %}
|
||||||
|
<article class="ak-chart-card rounded-2xl border p-5 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="ak-chart-title text-sm">Security Event Types</h3>
|
||||||
|
<p class="ak-chart-subtitle mt-1 text-xs">Click a bar to open logs filtered to that event type.</p>
|
||||||
|
</div>
|
||||||
|
<span class="ak-chart-badge inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold">
|
||||||
|
Events: {{ event_types.total }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ak-chart-canvas-wrap mt-4 h-64">
|
||||||
|
<canvas id="js-security-events-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<p id="js-security-events-chart-empty" class="ak-chart-empty mt-2 text-xs {% if event_types.chart.labels %}hidden{% endif %}">No security event-type distribution available yet.</p>
|
||||||
|
</article>
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with failed_ips=audit_charts.failed_source_ips %}
|
||||||
|
<article class="ak-chart-card rounded-2xl border p-5 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="ak-chart-title text-sm">Failed Login Source IPs</h3>
|
||||||
|
<p class="ak-chart-subtitle mt-1 text-xs">Click a bar to inspect failed logins from that source IP.</p>
|
||||||
|
</div>
|
||||||
|
<span class="ak-chart-badge inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold">
|
||||||
|
Failed events: {{ failed_ips.total }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ak-chart-canvas-wrap mt-4 h-64">
|
||||||
|
<canvas id="js-failed-ips-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<p id="js-failed-ips-chart-empty" class="ak-chart-empty mt-2 text-xs {% if failed_ips.chart.labels %}hidden{% endif %}">No failed-login IP activity detected.</p>
|
||||||
|
</article>
|
||||||
|
{% endwith %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="js-audit-subpage-logs" class="{% if audit_subpage != 'logs' %}hidden{% endif %}">
|
||||||
|
<div id="js-audit-panel-grid" class="grid gap-4">
|
||||||
|
<article class="rounded-2xl border border-gray-200 bg-white shadow-sm">
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900">Panel 1</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" class="js-duplicate-panel rounded border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50">Duplicate</button>
|
||||||
|
<button type="button" class="js-close-panel rounded border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50" disabled>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="js-panel-content p-4">
|
||||||
|
{% include "servers/_audit_panel.html" with panel=initial_panel server=server %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{ audit_charts.login_attempts.chart|json_script:"js-login-chart-data" }}
|
||||||
|
{{ audit_charts.http_status_codes.chart|json_script:"js-http-status-chart-data" }}
|
||||||
|
{{ audit_charts.security_event_types.chart|json_script:"js-security-events-chart-data" }}
|
||||||
|
{{ audit_charts.failed_source_ips.chart|json_script:"js-failed-ips-chart-data" }}
|
||||||
|
|
||||||
|
<script src="{% static 'unfold/js/chart/chart.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var subpageButtons = document.querySelectorAll(".js-audit-subpage");
|
||||||
|
var chartsSection = document.getElementById("js-audit-subpage-charts");
|
||||||
|
var logsSection = document.getElementById("js-audit-subpage-logs");
|
||||||
|
var logControls = document.getElementById("js-audit-log-controls");
|
||||||
|
var panelCounter = 1;
|
||||||
|
var grid = document.getElementById("js-audit-panel-grid");
|
||||||
|
var addButton = document.getElementById("js-add-audit-panel");
|
||||||
|
var panelUrl = "{{ audit_panel_url }}";
|
||||||
|
var lineLimitSelect = document.getElementById("js-audit-line-limit");
|
||||||
|
var chartsInitialized = false;
|
||||||
|
var chartInstances = [];
|
||||||
|
var auditPath = "{{ request.path }}";
|
||||||
|
|
||||||
|
function parseChartData(scriptId) {
|
||||||
|
var script = document.getElementById(scriptId);
|
||||||
|
if (!script) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(script.textContent);
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResolvedTheme() {
|
||||||
|
return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChartPalette() {
|
||||||
|
if (getResolvedTheme() === "dark") {
|
||||||
|
return {
|
||||||
|
legend: "#e2e8f0",
|
||||||
|
tickPrimary: "#94a3b8",
|
||||||
|
tickSecondary: "#cbd5e1",
|
||||||
|
gridStrong: "rgba(148, 163, 184, 0.12)",
|
||||||
|
gridSoft: "rgba(148, 163, 184, 0.08)",
|
||||||
|
tooltipBg: "rgba(15, 23, 42, 0.94)",
|
||||||
|
tooltipBorder: "rgba(148, 163, 184, 0.35)",
|
||||||
|
tooltipTitle: "#e2e8f0",
|
||||||
|
tooltipBody: "#cbd5e1"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
legend: "#0f172a",
|
||||||
|
tickPrimary: "#64748b",
|
||||||
|
tickSecondary: "#334155",
|
||||||
|
gridStrong: "rgba(148, 163, 184, 0.24)",
|
||||||
|
gridSoft: "rgba(148, 163, 184, 0.16)",
|
||||||
|
tooltipBg: "rgba(248, 250, 252, 0.98)",
|
||||||
|
tooltipBorder: "rgba(100, 116, 139, 0.35)",
|
||||||
|
tooltipTitle: "#0f172a",
|
||||||
|
tooltipBody: "#334155"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBarChart(canvasId, emptyId, data, options) {
|
||||||
|
var canvas = document.getElementById(canvasId);
|
||||||
|
var emptyNode = document.getElementById(emptyId);
|
||||||
|
var chartOpts = options || {};
|
||||||
|
var palette = getChartPalette();
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (canvas._kwChart) {
|
||||||
|
canvas._kwChart.destroy();
|
||||||
|
canvas._kwChart = null;
|
||||||
|
}
|
||||||
|
if (!window.Chart || !data || !Array.isArray(data.labels) || data.labels.length === 0) {
|
||||||
|
canvas.classList.add("hidden");
|
||||||
|
if (emptyNode) {
|
||||||
|
emptyNode.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (emptyNode) {
|
||||||
|
emptyNode.classList.add("hidden");
|
||||||
|
}
|
||||||
|
canvas.classList.remove("hidden");
|
||||||
|
var chartData = {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: chartOpts.datasetLabel || "Events",
|
||||||
|
data: data.values,
|
||||||
|
backgroundColor: data.colors,
|
||||||
|
borderRadius: 4,
|
||||||
|
borderSkipped: false,
|
||||||
|
maxBarThickness: chartOpts.maxBarThickness || 36
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var chart = new window.Chart(canvas.getContext("2d"), {
|
||||||
|
type: "bar",
|
||||||
|
data: chartData,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
indexAxis: chartOpts.indexAxis || "x",
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: "top",
|
||||||
|
align: "center",
|
||||||
|
labels: {
|
||||||
|
color: palette.legend,
|
||||||
|
boxWidth: 18,
|
||||||
|
boxHeight: 8,
|
||||||
|
font: {
|
||||||
|
size: 12,
|
||||||
|
weight: "600"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: palette.tooltipBg,
|
||||||
|
borderColor: palette.tooltipBorder,
|
||||||
|
borderWidth: 1,
|
||||||
|
titleColor: palette.tooltipTitle,
|
||||||
|
bodyColor: palette.tooltipBody,
|
||||||
|
callbacks: {
|
||||||
|
label: function (ctx) {
|
||||||
|
var isHorizontal = chartOpts.indexAxis === "y";
|
||||||
|
var value = isHorizontal ? ctx.parsed.x : ctx.parsed.y;
|
||||||
|
return "Count: " + value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
precision: 0,
|
||||||
|
color: palette.tickPrimary
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: palette.gridStrong,
|
||||||
|
drawBorder: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: {
|
||||||
|
color: palette.tickSecondary
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: palette.gridSoft,
|
||||||
|
drawBorder: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClick: function (_, activeElements) {
|
||||||
|
if (!activeElements || !activeElements.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var index = activeElements[0].index;
|
||||||
|
var query = (data.drilldowns || [])[index] || "";
|
||||||
|
if (!query) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = auditPath + "?" + query;
|
||||||
|
},
|
||||||
|
onHover: function (event, activeElements) {
|
||||||
|
var target = event && event.native ? event.native.target : null;
|
||||||
|
if (!target || !target.style) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.style.cursor = activeElements && activeElements.length ? "pointer" : "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
canvas._kwChart = chart;
|
||||||
|
chartInstances.push(chart);
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyCharts() {
|
||||||
|
for (var i = 0; i < chartInstances.length; i += 1) {
|
||||||
|
try {
|
||||||
|
chartInstances[i].destroy();
|
||||||
|
} catch (err) {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chartInstances = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeCharts(force) {
|
||||||
|
if (chartsInitialized && !force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (force) {
|
||||||
|
destroyCharts();
|
||||||
|
}
|
||||||
|
chartsInitialized = true;
|
||||||
|
renderBarChart("js-login-chart", "js-login-chart-empty", parseChartData("js-login-chart-data"), {
|
||||||
|
datasetLabel: "Logins"
|
||||||
|
});
|
||||||
|
renderBarChart("js-http-status-chart", "js-http-status-chart-empty", parseChartData("js-http-status-chart-data"), {
|
||||||
|
datasetLabel: "HTTP status"
|
||||||
|
});
|
||||||
|
renderBarChart("js-security-events-chart", "js-security-events-chart-empty", parseChartData("js-security-events-chart-data"), {
|
||||||
|
datasetLabel: "Events"
|
||||||
|
});
|
||||||
|
renderBarChart("js-failed-ips-chart", "js-failed-ips-chart-empty", parseChartData("js-failed-ips-chart-data"), {
|
||||||
|
datasetLabel: "Failed logins",
|
||||||
|
indexAxis: "y",
|
||||||
|
maxBarThickness: 24
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSubpageButtonState(button, isActive) {
|
||||||
|
if (isActive) {
|
||||||
|
button.classList.remove("bg-gray-100", "text-gray-600", "hover:bg-white", "hover:text-gray-900");
|
||||||
|
button.classList.add("bg-blue-50", "text-blue-700", "ring-1", "ring-blue-100");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.classList.remove("bg-blue-50", "text-blue-700", "ring-1", "ring-blue-100");
|
||||||
|
button.classList.add("bg-gray-100", "text-gray-600", "hover:bg-white", "hover:text-gray-900");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSubpage(view, updateUrl) {
|
||||||
|
var target = view === "charts" ? "charts" : "logs";
|
||||||
|
if (chartsSection) {
|
||||||
|
chartsSection.classList.toggle("hidden", target !== "charts");
|
||||||
|
}
|
||||||
|
if (logsSection) {
|
||||||
|
logsSection.classList.toggle("hidden", target !== "logs");
|
||||||
|
}
|
||||||
|
if (logControls) {
|
||||||
|
logControls.classList.toggle("hidden", target !== "logs");
|
||||||
|
}
|
||||||
|
if (target === "charts") {
|
||||||
|
initializeCharts();
|
||||||
|
}
|
||||||
|
for (var i = 0; i < subpageButtons.length; i += 1) {
|
||||||
|
var button = subpageButtons[i];
|
||||||
|
setSubpageButtonState(button, button.getAttribute("data-audit-subpage") === target);
|
||||||
|
}
|
||||||
|
if (!updateUrl || !window.history || !window.history.replaceState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("view", target);
|
||||||
|
window.history.replaceState({}, "", url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedLineLimit() {
|
||||||
|
if (!lineLimitSelect) {
|
||||||
|
return "{{ audit_line_limit }}";
|
||||||
|
}
|
||||||
|
return lineLimitSelect.value || "{{ audit_line_limit }}";
|
||||||
|
}
|
||||||
|
|
||||||
|
function withLineLimit(queryString) {
|
||||||
|
var params = new URLSearchParams(queryString || "");
|
||||||
|
params.set("line_limit", getSelectedLineLimit());
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGridLayout() {
|
||||||
|
if (!grid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var panels = grid.querySelectorAll("article").length;
|
||||||
|
if (panels > 1) {
|
||||||
|
grid.classList.add("lg:grid-cols-2");
|
||||||
|
} else {
|
||||||
|
grid.classList.remove("lg:grid-cols-2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCloseButtons() {
|
||||||
|
if (!grid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var panels = grid.querySelectorAll("article");
|
||||||
|
for (var i = 0; i < panels.length; i += 1) {
|
||||||
|
var closeBtn = panels[i].querySelector(".js-close-panel");
|
||||||
|
if (!closeBtn) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
closeBtn.disabled = panels.length <= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPanelContent(contentNode, queryString) {
|
||||||
|
if (!contentNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var url = panelUrl;
|
||||||
|
var normalizedQuery = withLineLimit(queryString);
|
||||||
|
if (normalizedQuery) {
|
||||||
|
url += (url.indexOf("?") === -1 ? "?" : "&") + normalizedQuery;
|
||||||
|
}
|
||||||
|
fetch(url, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "X-Requested-With": "XMLHttpRequest" }
|
||||||
|
})
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to load panel");
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.then(function (html) {
|
||||||
|
contentNode.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
contentNode.innerHTML = "<p class=\"text-sm text-rose-700\">Could not load panel data.</p>";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPanel(queryString) {
|
||||||
|
if (!grid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panelCounter += 1;
|
||||||
|
var article = document.createElement("article");
|
||||||
|
article.className = "rounded-2xl border border-gray-200 bg-white shadow-sm";
|
||||||
|
article.innerHTML =
|
||||||
|
"<div class=\"flex items-center justify-between border-b border-gray-100 px-4 py-3\">" +
|
||||||
|
"<h3 class=\"text-sm font-semibold text-gray-900\">Panel " + panelCounter + "</h3>" +
|
||||||
|
"<div class=\"flex items-center gap-2\">" +
|
||||||
|
"<button type=\"button\" class=\"js-duplicate-panel rounded border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50\">Duplicate</button>" +
|
||||||
|
"<button type=\"button\" class=\"js-close-panel rounded border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50\">Close</button>" +
|
||||||
|
"</div>" +
|
||||||
|
"</div>" +
|
||||||
|
"<div class=\"js-panel-content p-4\"><p class=\"text-sm text-gray-500\">Loading…</p></div>";
|
||||||
|
grid.appendChild(article);
|
||||||
|
setCloseButtons();
|
||||||
|
updateGridLayout();
|
||||||
|
loadPanelContent(article.querySelector(".js-panel-content"), queryString || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var subpageIndex = 0; subpageIndex < subpageButtons.length; subpageIndex += 1) {
|
||||||
|
subpageButtons[subpageIndex].addEventListener("click", function (event) {
|
||||||
|
var target = event.currentTarget;
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubpage(target.getAttribute("data-audit-subpage") || "logs", true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSubpage("{{ audit_subpage }}", false);
|
||||||
|
|
||||||
|
window.addEventListener("keywarden:theme-mode", function () {
|
||||||
|
if (chartsSection && !chartsSection.classList.contains("hidden")) {
|
||||||
|
initializeCharts(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
destroyCharts();
|
||||||
|
chartsInitialized = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (addButton) {
|
||||||
|
addButton.addEventListener("click", function () {
|
||||||
|
createPanel("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineLimitSelect && grid) {
|
||||||
|
lineLimitSelect.addEventListener("change", function () {
|
||||||
|
var panels = grid.querySelectorAll("article");
|
||||||
|
for (var i = 0; i < panels.length; i += 1) {
|
||||||
|
var panel = panels[i];
|
||||||
|
var form = panel.querySelector(".js-audit-panel-form");
|
||||||
|
var queryString = "";
|
||||||
|
if (form) {
|
||||||
|
queryString = new URLSearchParams(new FormData(form)).toString();
|
||||||
|
}
|
||||||
|
loadPanelContent(panel.querySelector(".js-panel-content"), queryString);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.addEventListener("click", function (event) {
|
||||||
|
var target = event.target;
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.classList.contains("js-close-panel")) {
|
||||||
|
var panel = target.closest("article");
|
||||||
|
if (!panel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
panel.remove();
|
||||||
|
setCloseButtons();
|
||||||
|
updateGridLayout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.classList.contains("js-duplicate-panel")) {
|
||||||
|
var currentPanel = target.closest("article");
|
||||||
|
if (!currentPanel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var form = currentPanel.querySelector(".js-audit-panel-form");
|
||||||
|
var queryString = "";
|
||||||
|
if (form) {
|
||||||
|
queryString = new URLSearchParams(new FormData(form)).toString();
|
||||||
|
}
|
||||||
|
createPanel(queryString);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.classList.contains("js-panel-link")) {
|
||||||
|
event.preventDefault();
|
||||||
|
var href = target.getAttribute("href") || "";
|
||||||
|
var idx = href.indexOf("?");
|
||||||
|
var query = idx >= 0 ? href.substring(idx + 1) : "";
|
||||||
|
var panelForLink = target.closest("article");
|
||||||
|
if (!panelForLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadPanelContent(panelForLink.querySelector(".js-panel-content"), query);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.addEventListener("submit", function (event) {
|
||||||
|
var form = event.target;
|
||||||
|
if (!(form instanceof HTMLFormElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.classList.contains("js-audit-panel-form")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
var panel = form.closest("article");
|
||||||
|
if (!panel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var queryString = new URLSearchParams(new FormData(form)).toString();
|
||||||
|
loadPanelContent(panel.querySelector(".js-panel-content"), queryString);
|
||||||
|
});
|
||||||
|
|
||||||
|
setCloseButtons();
|
||||||
|
updateGridLayout();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Servers</h1>
|
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Servers</h1>
|
||||||
<p class="mt-1 text-sm text-gray-500">Review the servers you can access and their certificate status.</p>
|
<p class="mt-1 text-sm text-gray-500">Request access, review approvals, and open servers you are approved for.</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
|
<span class="inline-flex items-center rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700">
|
||||||
{{ servers|length }} total
|
{{ servers|length }} total
|
||||||
@@ -18,93 +18,140 @@
|
|||||||
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{% for item in servers %}
|
{% for item in servers %}
|
||||||
<article class="flex h-full flex-col rounded-2xl border border-gray-200 bg-white p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
|
<article class="flex h-full flex-col rounded-2xl border border-gray-200 bg-white p-5 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
|
||||||
<div class="flex items-start justify-between">
|
{% if item.access.granted %}
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-700 text-sm font-semibold text-white">
|
<div class="flex items-center gap-3">
|
||||||
{{ item.server.initial }}
|
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-700 text-sm font-semibold text-white">
|
||||||
|
{{ item.server.initial }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">{{ item.server.display_name }}</h2>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
{{ item.server.hostname|default:item.server.ipv4|default:item.server.ipv6|default:"Unassigned" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="relative">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">{{ item.server.display_name }}</h2>
|
<button
|
||||||
<p class="text-xs text-gray-500">
|
type="button"
|
||||||
{{ item.server.hostname|default:item.server.ipv4|default:item.server.ipv6|default:"Unassigned" }}
|
data-tooltip-target="server-status-{{ item.server.id }}"
|
||||||
</p>
|
class="{% if item.status.is_active %}inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700{% else %}inline-flex items-center rounded-full bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700{% endif %}"
|
||||||
</div>
|
>
|
||||||
</div>
|
{{ item.status.label }}: {{ item.status.detail }}
|
||||||
<div class="relative">
|
</button>
|
||||||
<button
|
<div
|
||||||
type="button"
|
id="server-status-{{ item.server.id }}"
|
||||||
data-tooltip-target="server-status-{{ item.server.id }}"
|
role="tooltip"
|
||||||
class="{% if item.status.is_active %}inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700{% else %}inline-flex items-center rounded-full bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700{% endif %}"
|
class="invisible absolute z-10 inline-block w-64 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-sm opacity-0 transition-opacity"
|
||||||
>
|
>
|
||||||
{{ item.status.label }}: {{ item.status.detail }}
|
<div class="space-y-1">
|
||||||
</button>
|
<div class="flex items-center justify-between">
|
||||||
<div
|
<span class="font-semibold text-gray-500">Status</span>
|
||||||
id="server-status-{{ item.server.id }}"
|
<span class="font-medium text-gray-900">{{ item.status.label }}: {{ item.status.detail }}</span>
|
||||||
role="tooltip"
|
</div>
|
||||||
class="invisible absolute z-10 inline-block w-64 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-sm opacity-0 transition-opacity"
|
<div class="flex items-center justify-between">
|
||||||
>
|
<span class="font-semibold text-gray-500">Ping</span>
|
||||||
<div class="space-y-1">
|
<span class="font-medium text-gray-900">
|
||||||
<div class="flex items-center justify-between">
|
{% if item.status.ping_ms is not None %}{{ item.status.ping_ms }}ms{% else %}—{% endif %}
|
||||||
<span class="font-semibold text-gray-500">Status</span>
|
</span>
|
||||||
<span class="font-medium text-gray-900">{{ item.status.label }}: {{ item.status.detail }}</span>
|
</div>
|
||||||
</div>
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center justify-between">
|
<span class="font-semibold text-gray-500">Hostname</span>
|
||||||
<span class="font-semibold text-gray-500">Ping</span>
|
<span class="font-medium text-gray-900">{{ item.server.hostname|default:"—" }}</span>
|
||||||
<span class="font-medium text-gray-900">
|
</div>
|
||||||
{% if item.status.ping_ms is not None %}{{ item.status.ping_ms }}ms{% else %}—{% endif %}
|
<div class="flex items-center justify-between">
|
||||||
</span>
|
<span class="font-semibold text-gray-500">IPv4</span>
|
||||||
</div>
|
<span class="font-medium text-gray-900">{{ item.server.ipv4|default:"—" }}</span>
|
||||||
<div class="flex items-center justify-between">
|
</div>
|
||||||
<span class="font-semibold text-gray-500">Hostname</span>
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-medium text-gray-900">{{ item.server.hostname|default:"—" }}</span>
|
<span class="font-semibold text-gray-500">IPv6</span>
|
||||||
</div>
|
<span class="font-medium text-gray-900">{{ item.server.ipv6|default:"—" }}</span>
|
||||||
<div class="flex items-center justify-between">
|
</div>
|
||||||
<span class="font-semibold text-gray-500">IPv4</span>
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-medium text-gray-900">{{ item.server.ipv4|default:"—" }}</span>
|
<span class="font-semibold text-gray-500">Last heartbeat</span>
|
||||||
</div>
|
<span class="font-medium text-gray-900">
|
||||||
<div class="flex items-center justify-between">
|
{% if item.status.heartbeat_at %}{{ item.status.heartbeat_at|date:"M j, Y H:i:s" }}{% else %}—{% endif %}
|
||||||
<span class="font-semibold text-gray-500">IPv6</span>
|
</span>
|
||||||
<span class="font-medium text-gray-900">{{ item.server.ipv6|default:"—" }}</span>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="font-semibold text-gray-500">Last heartbeat</span>
|
|
||||||
<span class="font-medium text-gray-900">
|
|
||||||
{% if item.status.heartbeat_at %}{{ item.status.heartbeat_at|date:"M j, Y H:i:s" }}{% else %}—{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tooltip-arrow" data-popper-arrow></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<dl class="mt-5 divide-y divide-gray-100 text-sm text-gray-600">
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
<dl class="mt-5 divide-y divide-gray-100 text-sm text-gray-600">
|
<dt>Access until</dt>
|
||||||
<div class="flex items-center justify-between py-2">
|
<dd class="font-medium text-gray-900">
|
||||||
<dt>Access until</dt>
|
{% if item.expires_at %}
|
||||||
<dd class="font-medium text-gray-900">
|
{{ item.expires_at|date:"M j, Y H:i" }}
|
||||||
{% if item.expires_at %}
|
{% else %}
|
||||||
{{ item.expires_at|date:"M j, Y H:i" }}
|
No expiry
|
||||||
{% else %}
|
{% endif %}
|
||||||
No expiry
|
</dd>
|
||||||
{% endif %}
|
</div>
|
||||||
</dd>
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt>Scopes</dt>
|
||||||
|
<dd class="font-medium text-gray-900">
|
||||||
|
<div class="flex flex-wrap justify-end gap-1">
|
||||||
|
{% if item.access.can_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
|
||||||
|
{% if item.access.can_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
|
||||||
|
{% if item.access.can_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div class="mt-5 border-t border-gray-100 pt-4">
|
||||||
|
{% if item.access.can_users %}
|
||||||
|
<a
|
||||||
|
href="{% url 'servers:detail' item.server.id %}"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-full bg-blue-700 px-4 py-2 text-xs font-semibold text-white transition hover:bg-blue-800"
|
||||||
|
>
|
||||||
|
Open details
|
||||||
|
</a>
|
||||||
|
{% elif item.access.can_logs %}
|
||||||
|
<a
|
||||||
|
href="{% url 'servers:audit' item.server.id %}"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-full bg-blue-700 px-4 py-2 text-xs font-semibold text-white transition hover:bg-blue-800"
|
||||||
|
>
|
||||||
|
Open audit
|
||||||
|
</a>
|
||||||
|
{% elif item.access.can_shell %}
|
||||||
|
<a
|
||||||
|
href="{% url 'servers:shell' item.server.id %}"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-full bg-blue-700 px-4 py-2 text-xs font-semibold text-white transition hover:bg-blue-800"
|
||||||
|
>
|
||||||
|
Open shell
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between py-2">
|
{% else %}
|
||||||
<dt>Last accessed</dt>
|
<div class="flex items-start justify-between">
|
||||||
<dd class="font-medium text-gray-900">
|
<div>
|
||||||
{% if item.last_accessed %}
|
<h2 class="text-lg font-semibold text-gray-900">{{ item.server.hostname|default:"Unassigned" }}</h2>
|
||||||
{{ item.last_accessed|date:"M j, Y H:i" }}
|
<p class="mt-1 text-xs text-gray-500">No access granted</p>
|
||||||
{% else %}
|
</div>
|
||||||
—
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Hostname only</span>
|
||||||
{% endif %}
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
<div class="mt-5 border-t border-gray-100 pt-4">
|
||||||
|
{% if item.access.pending %}
|
||||||
<div class="mt-5 flex items-center justify-between border-t border-gray-100 pt-4 text-xs text-gray-500">
|
<span class="inline-flex items-center rounded-lg bg-amber-100 px-3 py-1.5 text-xs font-semibold text-amber-800">
|
||||||
<span>Certificates and access</span>
|
Request pending since {{ item.access.pending.requested_at|date:"M j, Y H:i" }}
|
||||||
<a href="{% url 'servers:detail' item.server.id %}" class="font-semibold text-blue-700 hover:underline">View details</a>
|
</span>
|
||||||
</div>
|
{% else %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-800"
|
||||||
|
data-open-request-modal
|
||||||
|
data-server-id="{{ item.server.id }}"
|
||||||
|
data-server-name="{{ item.server.display_name|default:item.server.hostname|default:item.server.ipv4|default:item.server.ipv6|default:'Server'|escape }}"
|
||||||
|
data-request-url="{% url 'servers:request_access' item.server.id %}"
|
||||||
|
>
|
||||||
|
Request access
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<p class="mt-2 text-xs text-gray-500">Choose scopes, duration, and preferred server username in the request popup.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,9 +163,149 @@
|
|||||||
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
|
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="mt-4 text-lg font-semibold text-gray-900">No server access yet</h2>
|
<h2 class="mt-4 text-lg font-semibold text-gray-900">No servers yet</h2>
|
||||||
<p class="mt-2 text-sm text-gray-600">Request access to a server to see it listed here.</p>
|
<p class="mt-2 text-sm text-gray-600">Create or enroll a server to start handling access requests.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="access-request-modal" class="fixed inset-0 z-50 hidden items-center justify-center px-4 py-6" aria-hidden="true">
|
||||||
|
<div class="absolute inset-0 bg-gray-900/50" data-request-modal-close></div>
|
||||||
|
<div class="relative z-10 w-full max-w-xl rounded-2xl border border-gray-200 bg-white p-5 shadow-xl sm:p-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Request Scoped Access</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Server:
|
||||||
|
<span id="request-modal-server-name" class="font-semibold text-gray-700">-</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-gray-200 text-gray-500 hover:bg-gray-50"
|
||||||
|
data-request-modal-close
|
||||||
|
aria-label="Close access request"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="access-request-form" method="post" class="mt-5 space-y-5">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="scoped_request" value="1">
|
||||||
|
|
||||||
|
<fieldset class="space-y-2">
|
||||||
|
<legend class="text-xs font-semibold uppercase tracking-wide text-gray-500">Requested scopes</legend>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input type="checkbox" name="request_shell" value="1" class="h-4 w-4 rounded border-gray-300 text-blue-700" checked>
|
||||||
|
Shell access
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input type="checkbox" name="request_logs" value="1" class="h-4 w-4 rounded border-gray-300 text-blue-700" checked>
|
||||||
|
Audit log access
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input type="checkbox" name="request_users" value="1" class="h-4 w-4 rounded border-gray-300 text-blue-700" checked>
|
||||||
|
Server users/details access
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="requested-duration-hours" class="block text-xs font-semibold uppercase tracking-wide text-gray-500">Requested duration</label>
|
||||||
|
<select id="requested-duration-hours" name="requested_duration_hours" class="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700">
|
||||||
|
<option value="">No preferred expiry</option>
|
||||||
|
<option value="1">1 hour</option>
|
||||||
|
<option value="4">4 hours</option>
|
||||||
|
<option value="8" selected>8 hours</option>
|
||||||
|
<option value="24">24 hours</option>
|
||||||
|
<option value="72">3 days</option>
|
||||||
|
<option value="168">7 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="requested-server-username" class="block text-xs font-semibold uppercase tracking-wide text-gray-500">Preferred server username</label>
|
||||||
|
<input
|
||||||
|
id="requested-server-username"
|
||||||
|
name="requested_server_username"
|
||||||
|
type="text"
|
||||||
|
maxlength="128"
|
||||||
|
value="{{ request.user.username }}"
|
||||||
|
placeholder="e.g. ubuntu"
|
||||||
|
class="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="request-reason" class="block text-xs font-semibold uppercase tracking-wide text-gray-500">Reason (optional)</label>
|
||||||
|
<textarea
|
||||||
|
id="request-reason"
|
||||||
|
name="reason"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Why you need this access."
|
||||||
|
class="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-2 border-t border-gray-100 pt-4">
|
||||||
|
<button type="button" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:bg-gray-50" data-request-modal-close>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white hover:bg-blue-800">
|
||||||
|
Submit request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var modal = document.getElementById("access-request-modal");
|
||||||
|
var modalServerName = document.getElementById("request-modal-server-name");
|
||||||
|
var form = document.getElementById("access-request-form");
|
||||||
|
var openButtons = document.querySelectorAll("[data-open-request-modal]");
|
||||||
|
var closeButtons = document.querySelectorAll("[data-request-modal-close]");
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modal.classList.add("hidden");
|
||||||
|
modal.classList.remove("flex");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(button) {
|
||||||
|
var requestUrl = button.getAttribute("data-request-url");
|
||||||
|
var serverName = button.getAttribute("data-server-name") || "Server";
|
||||||
|
form.setAttribute("action", requestUrl);
|
||||||
|
modalServerName.textContent = serverName;
|
||||||
|
modal.classList.remove("hidden");
|
||||||
|
modal.classList.add("flex");
|
||||||
|
modal.setAttribute("aria-hidden", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
openButtons.forEach(function (button) {
|
||||||
|
button.addEventListener("click", function () {
|
||||||
|
openModal(button);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
closeButtons.forEach(function (button) {
|
||||||
|
button.addEventListener("click", closeModal);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (event) {
|
||||||
|
if (event.key === "Escape" && modal.classList.contains("flex")) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", function (event) {
|
||||||
|
var checked = form.querySelectorAll("input[name='request_shell']:checked, input[name='request_logs']:checked, input[name='request_users']:checked");
|
||||||
|
if (!checked.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
window.alert("Select at least one scope.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -129,11 +129,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
function getCookie(name) {
|
function getCsrfToken() {
|
||||||
var value = "; " + document.cookie;
|
if (typeof window.keywardenGetCsrfToken === "function") {
|
||||||
var parts = value.split("; " + name + "=");
|
return window.keywardenGetCsrfToken();
|
||||||
if (parts.length === 2) {
|
|
||||||
return parts.pop().split(";").shift();
|
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -156,7 +154,7 @@
|
|||||||
if (!window.confirm("Regenerate the certificate for this key?")) {
|
if (!window.confirm("Regenerate the certificate for this key?")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var csrf = getCookie("csrftoken");
|
var csrf = getCsrfToken();
|
||||||
fetch("/api/v1/keys/" + keyId + "/certificate", {
|
fetch("/api/v1/keys/" + keyId + "/certificate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load tz %}
|
||||||
|
|
||||||
|
{% block title %}Admin • {{ server.display_name }} • Keywarden{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
{% include "servers/_header.html" %}
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Server admin utilities</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Server-scoped administration and maintenance actions.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="{{ admin_server_url }}" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50">
|
||||||
|
Edit server
|
||||||
|
</a>
|
||||||
|
<a href="{{ admin_log_sources_url }}" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">
|
||||||
|
Log sources
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-700">
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-gray-500">Agent enrolled</dt>
|
||||||
|
<dd class="font-medium text-gray-900">{% if server.agent_enrolled_at %}Yes{% else %}No{% endif %}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-gray-500">Enrollment timestamp</dt>
|
||||||
|
<dd class="font-medium text-gray-900">{{ server.agent_enrolled_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if server.agent_enrolled_at %} UTC{% endif %}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-gray-500">Last heartbeat</dt>
|
||||||
|
<dd class="font-medium text-gray-900">{{ server.last_heartbeat_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if server.last_heartbeat_at %} UTC{% endif %}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between gap-3 border-b border-gray-100 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Pending access requests</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Approve or deny pending requests for this server.</p>
|
||||||
|
</div>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">
|
||||||
|
{{ pending_access_requests|length }} pending
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if pending_access_requests %}
|
||||||
|
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 font-semibold">Requester</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Requested scopes</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Requested at</th>
|
||||||
|
<th class="px-3 py-2 font-semibold text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 text-gray-700">
|
||||||
|
{% for req in pending_access_requests %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2">{{ req.requester.username }}</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% if req.request_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
|
||||||
|
{% if req.request_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
|
||||||
|
{% if req.request_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
|
||||||
|
{% if req.requested_duration_hours %}<span class="inline-flex rounded-full bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700">{{ req.requested_duration_hours }}h</span>{% endif %}
|
||||||
|
{% if req.requested_server_username %}<span class="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs font-semibold text-gray-700">@{{ req.requested_server_username }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">{{ req.requested_at|date:"M j, Y H:i" }}</td>
|
||||||
|
<td class="px-3 py-2 text-right">
|
||||||
|
<div class="inline-flex gap-2">
|
||||||
|
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="approve">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700">Approve</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{% url 'servers:decide_access_request' req.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="deny">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700">Deny</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-4 text-sm text-gray-500">No pending requests for this server.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="border-b border-gray-100 pb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Users with access</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Effective access from approved requests and object permissions.</p>
|
||||||
|
</div>
|
||||||
|
{% if users_with_access %}
|
||||||
|
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 font-semibold">User</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Scopes</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Source</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Requests</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Expires</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 text-gray-700">
|
||||||
|
{% for entry in users_with_access %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<div class="font-medium text-gray-900">{{ entry.user.username }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ entry.user.email|default:"—" }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{% if entry.can_shell %}<span class="inline-flex rounded-full bg-blue-50 px-2 py-0.5 text-xs font-semibold text-blue-700">Shell</span>{% endif %}
|
||||||
|
{% if entry.can_logs %}<span class="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">Logs</span>{% endif %}
|
||||||
|
{% if entry.can_users %}<span class="inline-flex rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Users</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
{% for label in entry.source_labels %}
|
||||||
|
<span class="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-xs font-semibold text-gray-700">{{ label }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">{{ entry.request_count }}</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
{% if entry.expires_at %}
|
||||||
|
{{ entry.expires_at|date:"M j, Y H:i" }}
|
||||||
|
{% else %}
|
||||||
|
No expiry
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-4 text-sm text-gray-500">No users currently have access on this server.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="border-b border-gray-100 pb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Server accounts</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Accounts currently tracked by agent synchronization.</p>
|
||||||
|
</div>
|
||||||
|
{% if server_accounts %}
|
||||||
|
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 font-semibold">System username</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">User</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Present</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Last synced</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 text-gray-700">
|
||||||
|
{% for account in server_accounts %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2 font-medium text-gray-900">{{ account.system_username }}</td>
|
||||||
|
<td class="px-3 py-2">{{ account.user.username }}</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
{% if account.is_present %}
|
||||||
|
<span class="inline-flex rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-semibold text-emerald-700">Present</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex rounded-full bg-rose-100 px-2 py-0.5 text-xs font-semibold text-rose-700">Missing</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">{{ account.last_synced_at|utc|date:"M j, Y H:i:s" }} UTC</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-4 text-sm text-gray-500">No synchronized server accounts yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% load tz %}
|
||||||
|
|
||||||
{% block title %}Settings • {{ server.display_name }} • Keywarden{% endblock %}
|
{% block title %}Settings • {{ server.display_name }} • Keywarden{% endblock %}
|
||||||
|
|
||||||
@@ -7,22 +8,130 @@
|
|||||||
{% include "servers/_header.html" %}
|
{% include "servers/_header.html" %}
|
||||||
|
|
||||||
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 pb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Settings</h2>
|
<h2 class="text-lg font-semibold text-gray-900">Server status</h2>
|
||||||
<p class="mt-1 text-sm text-gray-500">Manage server-level access policies and metadata.</p>
|
<p class="mt-1 text-sm text-gray-500">Current enrollment and telemetry posture for this host.</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">Placeholder</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
|
<dl class="mt-4 divide-y divide-gray-100 text-sm text-gray-700">
|
||||||
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
|
<div class="flex items-center justify-between py-2">
|
||||||
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<dt class="text-gray-500">Agent enrolled</dt>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" />
|
<dd class="font-medium text-gray-900">{% if server.agent_enrolled_at %}Yes{% else %}No{% endif %}</dd>
|
||||||
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
|
</div>
|
||||||
</svg>
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-gray-500">Enrollment timestamp</dt>
|
||||||
|
<dd class="font-medium text-gray-900">{{ server.agent_enrolled_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if server.agent_enrolled_at %} UTC{% endif %}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-gray-500">Last heartbeat</dt>
|
||||||
|
<dd class="font-medium text-gray-900">{{ server.last_heartbeat_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if server.last_heartbeat_at %} UTC{% endif %}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between py-2">
|
||||||
|
<dt class="text-gray-500">Last ping</dt>
|
||||||
|
<dd class="font-medium text-gray-900">{% if server.last_ping_ms is not None %}{{ server.last_ping_ms }}ms{% else %}—{% endif %}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Audit pipeline</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Visibility into agent log ingestion and category distribution.</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-sm text-gray-600">Settings will appear here as server options are added.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<dl class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div class="rounded-xl border border-gray-100 bg-gray-50 p-4">
|
||||||
|
<dt class="text-xs uppercase tracking-wide text-gray-500">Total logs</dt>
|
||||||
|
<dd class="mt-1 text-2xl font-semibold text-gray-900">{{ audit_summary.total_count }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-gray-100 bg-gray-50 p-4">
|
||||||
|
<dt class="text-xs uppercase tracking-wide text-gray-500">Last 24h</dt>
|
||||||
|
<dd class="mt-1 text-2xl font-semibold text-gray-900">{{ audit_summary.recent_24h_count }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-gray-100 bg-gray-50 p-4">
|
||||||
|
<dt class="text-xs uppercase tracking-wide text-gray-500">Last event</dt>
|
||||||
|
<dd class="mt-1 text-sm font-semibold text-gray-900">{{ audit_summary.last_event_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if audit_summary.last_event_at %} UTC{% endif %}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-gray-100 bg-gray-50 p-4">
|
||||||
|
<dt class="text-xs uppercase tracking-wide text-gray-500">Last ingest</dt>
|
||||||
|
<dd class="mt-1 text-sm font-semibold text-gray-900">{{ audit_summary.last_ingest_at|utc|date:"M j, Y H:i:s"|default:"—" }}{% if audit_summary.last_ingest_at %} UTC{% endif %}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div class="mt-4">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900">Top categories</h3>
|
||||||
|
{% if audit_summary.top_categories %}
|
||||||
|
<ul class="mt-2 space-y-2 text-sm text-gray-700">
|
||||||
|
{% for item in audit_summary.top_categories %}
|
||||||
|
<li class="flex items-center justify-between rounded-lg border border-gray-100 px-3 py-2">
|
||||||
|
<span class="font-medium text-gray-900">{{ item.category }}</span>
|
||||||
|
<span class="text-gray-500">{{ item.total }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-2 text-sm text-gray-500">No agent logs have been ingested yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 border-t border-gray-100 pt-4">
|
||||||
|
<a href="{% url 'servers:audit' server.id %}" class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-xs font-semibold text-white hover:bg-blue-800">
|
||||||
|
Open audit log viewer
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Log source configuration</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">These sources are consumed by the agent from the central server config.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="{{ admin_server_url }}" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50">
|
||||||
|
Configure Inline
|
||||||
|
</a>
|
||||||
|
<a href="{{ admin_log_sources_url }}" class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800">
|
||||||
|
Open Log Source Admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if log_sources %}
|
||||||
|
<div class="mt-4 overflow-x-auto rounded-xl border border-gray-200">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
|
<thead class="bg-gray-50 text-left text-xs uppercase tracking-wide text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 font-semibold">Type</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Name</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Service/File</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Parser</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Matches</th>
|
||||||
|
<th class="px-3 py-2 font-semibold">Overrides</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 text-gray-700">
|
||||||
|
{% for source in log_sources %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-3 py-2">{{ source.kind }}</td>
|
||||||
|
<td class="px-3 py-2">{{ source.name|default:"—" }}</td>
|
||||||
|
<td class="px-3 py-2">{{ source.service_unit|default:source.file_path|default:"—" }}</td>
|
||||||
|
<td class="px-3 py-2">{{ source.parser|default:"none" }}</td>
|
||||||
|
<td class="px-3 py-2 text-xs text-gray-600">
|
||||||
|
in={{ source.include_matches|default:"{}" }}<br>
|
||||||
|
out={{ source.exclude_matches|default:"{}" }}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
{{ source.category_override|default:"(category default)" }} / {{ source.event_type_override|default:"(event default)" }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-4 text-sm text-gray-500">No custom log sources defined. Defaults are used by the agent.</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="mt-4 text-xs text-gray-500">Use the admin buttons above to configure per-server monitors (journal/service/file), parser mode, and include/exclude matches.</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -148,11 +148,9 @@
|
|||||||
<script src="{% static 'vendor/xterm/xterm.js' %}"></script>
|
<script src="{% static 'vendor/xterm/xterm.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
function getCookie(name) {
|
function getCsrfToken() {
|
||||||
var value = "; " + document.cookie;
|
if (typeof window.keywardenGetCsrfToken === "function") {
|
||||||
var parts = value.split("; " + name + "=");
|
return window.keywardenGetCsrfToken();
|
||||||
if (parts.length === 2) {
|
|
||||||
return parts.pop().split(";").shift();
|
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -175,7 +173,7 @@
|
|||||||
if (!window.confirm("Regenerate the certificate for this key?")) {
|
if (!window.confirm("Regenerate the certificate for this key?")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var csrf = getCookie("csrftoken");
|
var csrf = getCsrfToken();
|
||||||
fetch("/api/v1/keys/" + keyId + "/certificate", {
|
fetch("/api/v1/keys/" + keyId + "/certificate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
@@ -240,6 +238,7 @@
|
|||||||
var startButton = document.getElementById("shell-start");
|
var startButton = document.getElementById("shell-start");
|
||||||
var activeSocket = null;
|
var activeSocket = null;
|
||||||
var activeTerm = null;
|
var activeTerm = null;
|
||||||
|
var activeResizeHandler = null;
|
||||||
var popoutShell = document.getElementById("shell-popout-shell");
|
var popoutShell = document.getElementById("shell-popout-shell");
|
||||||
var isPopout = {{ is_popout|yesno:"true,false" }};
|
var isPopout = {{ is_popout|yesno:"true,false" }};
|
||||||
|
|
||||||
@@ -283,19 +282,40 @@
|
|||||||
startButton.classList.toggle("hover:bg-blue-800", !isRunning);
|
startButton.classList.toggle("hover:bg-blue-800", !isRunning);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopTerminal() {
|
function disposeTerminal() {
|
||||||
if (activeSocket) {
|
if (activeResizeHandler) {
|
||||||
|
window.removeEventListener("resize", activeResizeHandler);
|
||||||
|
activeResizeHandler = null;
|
||||||
|
}
|
||||||
|
if (activeTerm) {
|
||||||
|
try {
|
||||||
|
activeTerm.dispose();
|
||||||
|
} catch (err) {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
activeTerm = null;
|
||||||
|
}
|
||||||
|
if (termContainer) {
|
||||||
|
termContainer.innerHTML = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTerminal(options) {
|
||||||
|
var opts = options || {};
|
||||||
|
if (!opts.skipSocketClose && activeSocket) {
|
||||||
try {
|
try {
|
||||||
activeSocket.close();
|
activeSocket.close();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// noop
|
// noop
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
activeSocket = null;
|
||||||
if (termContainer) {
|
if (termContainer) {
|
||||||
termContainer.dataset.started = "0";
|
termContainer.dataset.started = "0";
|
||||||
}
|
}
|
||||||
activeSocket = null;
|
if (opts.disposeTerminal) {
|
||||||
activeTerm = null;
|
disposeTerminal();
|
||||||
|
}
|
||||||
setButtonState(false);
|
setButtonState(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,6 +323,7 @@
|
|||||||
if (!termContainer || !window.Terminal || termContainer.dataset.started === "1") {
|
if (!termContainer || !window.Terminal || termContainer.dataset.started === "1") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
disposeTerminal();
|
||||||
termContainer.dataset.started = "1";
|
termContainer.dataset.started = "1";
|
||||||
if (startButton) {
|
if (startButton) {
|
||||||
startButton.disabled = true;
|
startButton.disabled = true;
|
||||||
@@ -343,10 +364,18 @@
|
|||||||
socket.onclose = function () {
|
socket.onclose = function () {
|
||||||
term.write("\r\nSession closed.\r\n");
|
term.write("\r\nSession closed.\r\n");
|
||||||
if (activeSocket === socket) {
|
if (activeSocket === socket) {
|
||||||
stopTerminal();
|
// Keep the closed terminal output visible until the next start.
|
||||||
|
stopTerminal({
|
||||||
|
skipSocketClose: true,
|
||||||
|
disposeTerminal: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
socket.onerror = function () {
|
||||||
|
term.write("\r\nConnection error.\r\n");
|
||||||
|
};
|
||||||
|
|
||||||
term.onData(function (data) {
|
term.onData(function (data) {
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
socket.send(data);
|
socket.send(data);
|
||||||
@@ -356,11 +385,11 @@
|
|||||||
setButtonState(true);
|
setButtonState(true);
|
||||||
|
|
||||||
if (isPopout) {
|
if (isPopout) {
|
||||||
var onResize = function () {
|
activeResizeHandler = function () {
|
||||||
sizePopoutTerminal();
|
sizePopoutTerminal();
|
||||||
fitTerminal(term);
|
fitTerminal(term);
|
||||||
};
|
};
|
||||||
window.addEventListener("resize", onResize);
|
window.addEventListener("resize", activeResizeHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,7 +402,7 @@
|
|||||||
if (startButton) {
|
if (startButton) {
|
||||||
startButton.addEventListener("click", function () {
|
startButton.addEventListener("click", function () {
|
||||||
if (termContainer.dataset.started === "1") {
|
if (termContainer.dataset.started === "1") {
|
||||||
stopTerminal();
|
stopTerminal({ disposeTerminal: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startTerminal();
|
startTerminal();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# Package marker for Django test discovery.
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
from apps.access.models import AccessRequest
|
||||||
|
from apps.accounts.models import ErasureRequest
|
||||||
|
from apps.core import rbac
|
||||||
|
from apps.servers.models import Server, ServerAccount
|
||||||
|
from apps.servers.permissions import user_can_logs, user_can_shell, user_can_users, user_has_any_access
|
||||||
|
|
||||||
|
|
||||||
|
class ServerAccessScopeTests(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="alice",
|
||||||
|
email="alice@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
self.admin = User.objects.create_superuser(
|
||||||
|
username="admin",
|
||||||
|
email="admin@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
self.server = Server.objects.create(
|
||||||
|
display_name="db-01",
|
||||||
|
hostname="db-01.example.test",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _grant_perm(self, user, app_label: str, codename: str) -> None:
|
||||||
|
perm = Permission.objects.get(
|
||||||
|
content_type__app_label=app_label,
|
||||||
|
codename=codename,
|
||||||
|
)
|
||||||
|
user.user_permissions.add(perm)
|
||||||
|
|
||||||
|
def _create_access_request(self, **kwargs) -> AccessRequest:
|
||||||
|
payload = {
|
||||||
|
"requester": self.user,
|
||||||
|
"server": self.server,
|
||||||
|
"status": AccessRequest.Status.APPROVED,
|
||||||
|
"request_shell": False,
|
||||||
|
"request_logs": False,
|
||||||
|
"request_users": False,
|
||||||
|
}
|
||||||
|
payload.update(kwargs)
|
||||||
|
return AccessRequest.objects.create(**payload)
|
||||||
|
|
||||||
|
def test_scope_helpers_enforce_request_flags(self) -> None:
|
||||||
|
self._create_access_request(request_shell=True, request_logs=False, request_users=False)
|
||||||
|
|
||||||
|
self.assertTrue(user_can_shell(self.user, self.server))
|
||||||
|
self.assertFalse(user_can_logs(self.user, self.server))
|
||||||
|
self.assertFalse(user_can_users(self.user, self.server))
|
||||||
|
self.assertTrue(user_has_any_access(self.user, self.server))
|
||||||
|
|
||||||
|
def test_approved_request_without_scopes_does_not_grant_server_view(self) -> None:
|
||||||
|
self._create_access_request(request_shell=False, request_logs=False, request_users=False)
|
||||||
|
|
||||||
|
self.assertFalse(user_has_any_access(self.user, self.server))
|
||||||
|
self.assertFalse(self.user.has_perm("servers.view_server", self.server))
|
||||||
|
|
||||||
|
def test_manual_view_perm_allows_logs_and_users_without_access_request(self) -> None:
|
||||||
|
assign_perm("view_server", self.user, self.server)
|
||||||
|
|
||||||
|
self.assertTrue(user_can_logs(self.user, self.server))
|
||||||
|
self.assertTrue(user_can_users(self.user, self.server))
|
||||||
|
|
||||||
|
def test_audit_view_denied_when_logs_scope_not_granted(self) -> None:
|
||||||
|
self._create_access_request(request_users=True, request_logs=False)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("servers:audit", args=[self.server.id]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, reverse("servers:dashboard"))
|
||||||
|
|
||||||
|
def test_detail_view_denied_when_users_scope_not_granted(self) -> None:
|
||||||
|
self._create_access_request(request_logs=True, request_users=False)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("servers:detail", args=[self.server.id]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, reverse("servers:dashboard"))
|
||||||
|
|
||||||
|
def test_dashboard_request_access_creates_pending_request(self) -> None:
|
||||||
|
self._grant_perm(self.user, "access", "add_accessrequest")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("servers:request_access", args=[self.server.id]))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertTrue(
|
||||||
|
AccessRequest.objects.filter(
|
||||||
|
requester=self.user,
|
||||||
|
server=self.server,
|
||||||
|
status=AccessRequest.Status.PENDING,
|
||||||
|
request_shell=True,
|
||||||
|
request_logs=True,
|
||||||
|
request_users=True,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dashboard_request_access_scoped_options_are_saved(self) -> None:
|
||||||
|
self._grant_perm(self.user, "access", "add_accessrequest")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("servers:request_access", args=[self.server.id]),
|
||||||
|
{
|
||||||
|
"scoped_request": "1",
|
||||||
|
"request_shell": "1",
|
||||||
|
"request_logs": "1",
|
||||||
|
"requested_duration_hours": "24",
|
||||||
|
"requested_server_username": "ubuntu",
|
||||||
|
"reason": "Investigate auth failure",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
access_request = AccessRequest.objects.get(requester=self.user, server=self.server)
|
||||||
|
self.assertEqual(access_request.status, AccessRequest.Status.PENDING)
|
||||||
|
self.assertTrue(access_request.request_shell)
|
||||||
|
self.assertTrue(access_request.request_logs)
|
||||||
|
self.assertFalse(access_request.request_users)
|
||||||
|
self.assertEqual(access_request.requested_duration_hours, 24)
|
||||||
|
self.assertEqual(access_request.requested_server_username, "ubuntu")
|
||||||
|
self.assertEqual(access_request.reason, "Investigate auth failure")
|
||||||
|
|
||||||
|
def test_admin_can_approve_pending_request(self) -> None:
|
||||||
|
pending = AccessRequest.objects.create(
|
||||||
|
requester=self.user,
|
||||||
|
server=self.server,
|
||||||
|
status=AccessRequest.Status.PENDING,
|
||||||
|
request_shell=True,
|
||||||
|
request_logs=True,
|
||||||
|
request_users=True,
|
||||||
|
)
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("servers:decide_access_request", args=[pending.id]),
|
||||||
|
{"action": "approve"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
pending.refresh_from_db()
|
||||||
|
self.assertEqual(pending.status, AccessRequest.Status.APPROVED)
|
||||||
|
self.assertEqual(pending.decided_by_id, self.admin.id)
|
||||||
|
|
||||||
|
def test_admin_approve_uses_requested_duration_for_expiry(self) -> None:
|
||||||
|
pending = AccessRequest.objects.create(
|
||||||
|
requester=self.user,
|
||||||
|
server=self.server,
|
||||||
|
status=AccessRequest.Status.PENDING,
|
||||||
|
request_shell=True,
|
||||||
|
requested_duration_hours=4,
|
||||||
|
)
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
|
||||||
|
decision_started_at = timezone.now()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("servers:decide_access_request", args=[pending.id]),
|
||||||
|
{"action": "approve"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
pending.refresh_from_db()
|
||||||
|
self.assertEqual(pending.status, AccessRequest.Status.APPROVED)
|
||||||
|
self.assertIsNotNone(pending.expires_at)
|
||||||
|
self.assertGreaterEqual(pending.expires_at, decision_started_at + timedelta(hours=3, minutes=59))
|
||||||
|
|
||||||
|
def test_admin_dashboard_requires_admin(self) -> None:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("servers:admin_dashboard"))
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, reverse("servers:dashboard"))
|
||||||
|
|
||||||
|
def test_admin_dashboard_lists_pending_requests(self) -> None:
|
||||||
|
pending = AccessRequest.objects.create(
|
||||||
|
requester=self.user,
|
||||||
|
server=self.server,
|
||||||
|
status=AccessRequest.Status.PENDING,
|
||||||
|
request_shell=True,
|
||||||
|
)
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
response = self.client.get(reverse("servers:admin_dashboard"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, pending.requester.username)
|
||||||
|
self.assertContains(response, "Access request queue")
|
||||||
|
|
||||||
|
def test_admin_dashboard_users_view_shows_gdpr_marker(self) -> None:
|
||||||
|
ErasureRequest.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
reason="Please erase my profile data completely.",
|
||||||
|
status=ErasureRequest.Status.PENDING,
|
||||||
|
)
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("servers:admin_dashboard"), {"view": "users"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Send reset email")
|
||||||
|
self.assertContains(response, "GDPR request")
|
||||||
|
|
||||||
|
def test_admin_delete_user_from_dashboard(self) -> None:
|
||||||
|
User = get_user_model()
|
||||||
|
victim = User.objects.create_user(
|
||||||
|
username="victim",
|
||||||
|
email="victim@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("servers:admin_delete_user", args=[victim.id]),
|
||||||
|
{"next": reverse("servers:admin_dashboard")},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertFalse(User.objects.filter(id=victim.id).exists())
|
||||||
|
|
||||||
|
def test_admin_send_password_reset_from_dashboard(self) -> None:
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("servers:admin_send_password_reset", args=[self.user.id]),
|
||||||
|
{"next": reverse("servers:admin_dashboard")},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_admin_delete_server_from_dashboard(self) -> None:
|
||||||
|
server = Server.objects.create(display_name="delete-me", hostname="delete-me.example.test")
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("servers:admin_delete_server", args=[server.id]),
|
||||||
|
{"next": reverse("servers:admin_dashboard")},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertFalse(Server.objects.filter(id=server.id).exists())
|
||||||
|
|
||||||
|
def test_admin_revoke_grant_from_dashboard(self) -> None:
|
||||||
|
grant = AccessRequest.objects.create(
|
||||||
|
requester=self.user,
|
||||||
|
server=self.server,
|
||||||
|
status=AccessRequest.Status.APPROVED,
|
||||||
|
request_shell=True,
|
||||||
|
expires_at=timezone.now() + timedelta(days=3),
|
||||||
|
)
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("servers:admin_revoke_grant", args=[grant.id]),
|
||||||
|
{"next": reverse("servers:admin_dashboard")},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
grant.refresh_from_db()
|
||||||
|
self.assertEqual(grant.status, AccessRequest.Status.REVOKED)
|
||||||
|
self.assertEqual(grant.decided_by_id, self.admin.id)
|
||||||
|
self.assertIsNotNone(grant.expires_at)
|
||||||
|
|
||||||
|
def test_admin_change_grant_expiry_from_dashboard(self) -> None:
|
||||||
|
grant = AccessRequest.objects.create(
|
||||||
|
requester=self.user,
|
||||||
|
server=self.server,
|
||||||
|
status=AccessRequest.Status.APPROVED,
|
||||||
|
request_shell=True,
|
||||||
|
expires_at=timezone.now() + timedelta(days=1),
|
||||||
|
)
|
||||||
|
new_expiry = timezone.now() + timedelta(days=9)
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("servers:admin_change_grant_expiry", args=[grant.id]),
|
||||||
|
{
|
||||||
|
"expires_at": timezone.localtime(new_expiry).strftime("%Y-%m-%dT%H:%M"),
|
||||||
|
"next": reverse("servers:admin_dashboard"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
grant.refresh_from_db()
|
||||||
|
self.assertIsNotNone(grant.expires_at)
|
||||||
|
self.assertGreater(grant.expires_at, timezone.now() + timedelta(days=8))
|
||||||
|
|
||||||
|
def test_server_admin_requires_admin(self) -> None:
|
||||||
|
assign_perm("view_server", self.user, self.server)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("servers:server_admin", args=[self.server.id]))
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, reverse("servers:dashboard"))
|
||||||
|
|
||||||
|
def test_server_admin_lists_users_and_server_accounts(self) -> None:
|
||||||
|
AccessRequest.objects.create(
|
||||||
|
requester=self.user,
|
||||||
|
server=self.server,
|
||||||
|
status=AccessRequest.Status.APPROVED,
|
||||||
|
request_shell=True,
|
||||||
|
request_logs=True,
|
||||||
|
request_users=True,
|
||||||
|
)
|
||||||
|
ServerAccount.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
user=self.user,
|
||||||
|
system_username="alice_1",
|
||||||
|
is_present=True,
|
||||||
|
)
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
response = self.client.get(reverse("servers:server_admin", args=[self.server.id]))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Users with access")
|
||||||
|
self.assertContains(response, self.user.username)
|
||||||
|
self.assertContains(response, "Server accounts")
|
||||||
|
self.assertContains(response, "alice_1")
|
||||||
|
|
||||||
|
|
||||||
|
class ServerApiRouterTests(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="api-user",
|
||||||
|
email="api-user@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
self.server_allowed = Server.objects.create(display_name="api-allowed", hostname="api-allowed.example.test")
|
||||||
|
self.server_denied = Server.objects.create(display_name="api-denied", hostname="api-denied.example.test")
|
||||||
|
assign_perm("view_server", self.user, self.server_allowed)
|
||||||
|
|
||||||
|
def test_list_servers_returns_only_object_permitted_servers(self) -> None:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get("/api/v1/servers/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
ids = {item["id"] for item in payload}
|
||||||
|
self.assertIn(self.server_allowed.id, ids)
|
||||||
|
self.assertNotIn(self.server_denied.id, ids)
|
||||||
|
|
||||||
|
def test_get_server_forbidden_without_object_permission(self) -> None:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(f"/api/v1/servers/{self.server_denied.id}")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_get_server_not_found(self) -> None:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get("/api/v1/servers/999999")
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_update_server_requires_change_permission(self) -> None:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/v1/servers/{self.server_allowed.id}",
|
||||||
|
data={"display_name": "renamed"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_update_server_validates_display_name(self) -> None:
|
||||||
|
perm = Permission.objects.get(content_type__app_label="servers", codename="change_server")
|
||||||
|
self.user.user_permissions.add(perm)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
self.client.patch(
|
||||||
|
f"/api/v1/servers/{self.server_allowed.id}",
|
||||||
|
data={"display_name": " "},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_update_server_success(self) -> None:
|
||||||
|
perm = Permission.objects.get(content_type__app_label="servers", codename="change_server")
|
||||||
|
self.user.user_permissions.add(perm)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/v1/servers/{self.server_allowed.id}",
|
||||||
|
data={"display_name": "renamed-server"},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.server_allowed.refresh_from_db()
|
||||||
|
self.assertEqual(self.server_allowed.display_name, "renamed-server")
|
||||||
|
|
||||||
|
|
||||||
|
class CoreRbacTests(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="rbac-user",
|
||||||
|
email="rbac-user@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_role_normalization_and_assignment(self) -> None:
|
||||||
|
self.assertEqual(rbac.normalize_role("ADMIN"), rbac.ROLE_ADMIN)
|
||||||
|
self.assertEqual(rbac.set_user_role(self.user, "admin"), rbac.ROLE_ADMIN)
|
||||||
|
self.user.save(update_fields=["is_staff", "is_superuser"])
|
||||||
|
self.assertEqual(rbac.get_user_role(self.user), rbac.ROLE_ADMIN)
|
||||||
|
self.assertTrue(self.user.is_staff)
|
||||||
|
self.assertTrue(self.user.is_superuser)
|
||||||
|
|
||||||
|
self.assertEqual(rbac.set_user_role(self.user, "user"), rbac.ROLE_USER)
|
||||||
|
self.user.save(update_fields=["is_staff", "is_superuser"])
|
||||||
|
self.assertEqual(rbac.get_user_role(self.user), rbac.ROLE_USER)
|
||||||
|
self.assertFalse(self.user.is_staff)
|
||||||
|
self.assertFalse(self.user.is_superuser)
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
rbac.set_user_role(self.user, "invalid")
|
||||||
|
|
||||||
|
def test_require_auth_and_perms_guards(self) -> None:
|
||||||
|
with self.assertRaises(HttpError):
|
||||||
|
rbac.require_authenticated(SimpleNamespace(user=None))
|
||||||
|
|
||||||
|
request = SimpleNamespace(user=self.user)
|
||||||
|
with self.assertRaises(HttpError):
|
||||||
|
rbac.require_perms(request, "servers.change_server")
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.contrib.admin.sites import AdminSite
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import RequestFactory, TestCase, override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.access.models import AccessRequest
|
||||||
|
from apps.servers.admin import ServerAdmin
|
||||||
|
from apps.servers.models import Server, ServerAccount, ServerAuditLog, ServerLogSource
|
||||||
|
|
||||||
|
|
||||||
|
class ServerAdminDeleteTests(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
User = get_user_model()
|
||||||
|
self.superuser = User.objects.create_superuser(
|
||||||
|
username="admin-delete",
|
||||||
|
email="admin-delete@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="member-delete",
|
||||||
|
email="member-delete@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
self.site = AdminSite()
|
||||||
|
self.model_admin = ServerAdmin(Server, self.site)
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
|
||||||
|
def _request(self):
|
||||||
|
request = self.factory.post("/admin/servers/server/")
|
||||||
|
request.user = self.superuser
|
||||||
|
return request
|
||||||
|
|
||||||
|
@override_settings(KEYWARDEN_ADMIN_DELETE_PREVIEW_MAX_ITEMS=2)
|
||||||
|
def test_get_deleted_objects_summarizes_large_cascade(self) -> None:
|
||||||
|
server = Server.objects.create(display_name="srv-admin-delete")
|
||||||
|
ServerAuditLog.objects.bulk_create(
|
||||||
|
[
|
||||||
|
ServerAuditLog(
|
||||||
|
server=server,
|
||||||
|
event_at=timezone.now(),
|
||||||
|
category="auth",
|
||||||
|
event_type="login",
|
||||||
|
),
|
||||||
|
ServerAuditLog(
|
||||||
|
server=server,
|
||||||
|
event_at=timezone.now(),
|
||||||
|
category="auth",
|
||||||
|
event_type="logout",
|
||||||
|
),
|
||||||
|
ServerAuditLog(
|
||||||
|
server=server,
|
||||||
|
event_at=timezone.now(),
|
||||||
|
category="auth",
|
||||||
|
event_type="sudo",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
ServerAccount.objects.create(server=server, user=self.user, system_username="member_delete")
|
||||||
|
ServerLogSource.objects.create(server=server, kind=ServerLogSource.Kind.JOURNAL, name="system-journal")
|
||||||
|
AccessRequest.objects.create(requester=self.user, server=server, request_shell=True)
|
||||||
|
|
||||||
|
deleted_objects, model_count, perms_needed, protected = self.model_admin.get_deleted_objects(
|
||||||
|
Server.objects.filter(id=server.id),
|
||||||
|
self._request(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(perms_needed, set())
|
||||||
|
self.assertEqual(protected, [])
|
||||||
|
self.assertIn("Large cascade detected", " ".join(str(item) for item in deleted_objects))
|
||||||
|
self.assertEqual(model_count[Server._meta.verbose_name_plural], 1)
|
||||||
|
self.assertEqual(model_count[ServerAuditLog._meta.verbose_name_plural], 3)
|
||||||
|
self.assertEqual(model_count[ServerAccount._meta.verbose_name_plural], 1)
|
||||||
|
self.assertEqual(model_count[ServerLogSource._meta.verbose_name_plural], 1)
|
||||||
|
self.assertEqual(model_count[AccessRequest._meta.verbose_name_plural], 1)
|
||||||
|
|
||||||
|
@override_settings(KEYWARDEN_ADMIN_DELETE_PREVIEW_MAX_ITEMS=2)
|
||||||
|
def test_get_deleted_objects_accepts_plain_object_list(self) -> None:
|
||||||
|
server = Server.objects.create(display_name="srv-admin-delete-list")
|
||||||
|
ServerAuditLog.objects.bulk_create(
|
||||||
|
[
|
||||||
|
ServerAuditLog(
|
||||||
|
server=server,
|
||||||
|
event_at=timezone.now(),
|
||||||
|
category="auth",
|
||||||
|
event_type="login",
|
||||||
|
),
|
||||||
|
ServerAuditLog(
|
||||||
|
server=server,
|
||||||
|
event_at=timezone.now(),
|
||||||
|
category="auth",
|
||||||
|
event_type="logout",
|
||||||
|
),
|
||||||
|
ServerAuditLog(
|
||||||
|
server=server,
|
||||||
|
event_at=timezone.now(),
|
||||||
|
category="auth",
|
||||||
|
event_type="sudo",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted_objects, model_count, perms_needed, protected = self.model_admin.get_deleted_objects(
|
||||||
|
[server],
|
||||||
|
self._request(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(perms_needed, set())
|
||||||
|
self.assertEqual(protected, [])
|
||||||
|
self.assertIn("Large cascade detected", " ".join(str(item) for item in deleted_objects))
|
||||||
|
self.assertEqual(model_count[Server._meta.verbose_name_plural], 1)
|
||||||
|
self.assertEqual(model_count[ServerAuditLog._meta.verbose_name_plural], 3)
|
||||||
|
|
||||||
|
def test_delete_queryset_prepurges_audit_logs(self) -> None:
|
||||||
|
server = Server.objects.create(display_name="srv-admin-delete-purge")
|
||||||
|
ServerAuditLog.objects.bulk_create(
|
||||||
|
[
|
||||||
|
ServerAuditLog(
|
||||||
|
server=server,
|
||||||
|
event_at=timezone.now(),
|
||||||
|
category="auth",
|
||||||
|
event_type="login",
|
||||||
|
),
|
||||||
|
ServerAuditLog(
|
||||||
|
server=server,
|
||||||
|
event_at=timezone.now(),
|
||||||
|
category="auth",
|
||||||
|
event_type="logout",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.model_admin.delete_queryset(self._request(), Server.objects.filter(id=server.id))
|
||||||
|
|
||||||
|
self.assertFalse(Server.objects.filter(id=server.id).exists())
|
||||||
|
self.assertFalse(ServerAuditLog.objects.filter(server_id=server.id).exists())
|
||||||
@@ -0,0 +1,824 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from urllib.parse import quote
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
from apps.access.models import AccessRequest
|
||||||
|
from apps.keys.models import SSHKey
|
||||||
|
from apps.keys.models import SSHCertificate, SSHCertificateAuthority, parse_public_key
|
||||||
|
from apps.servers.models import AgentCertificateAuthority, EnrollmentToken, Server, ServerAuditLog, ServerLogSource
|
||||||
|
from keywarden.api.routers import agent as agent_router
|
||||||
|
|
||||||
|
|
||||||
|
class AgentLogIngestionTests(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
self.server = Server.objects.create(display_name="web-01", hostname="web-01.example.test")
|
||||||
|
self._configure_agent_mtls(self.server)
|
||||||
|
|
||||||
|
def _configure_agent_mtls(self, server: Server) -> None:
|
||||||
|
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
ca_subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Keywarden Test Agent CA")])
|
||||||
|
now = datetime.utcnow()
|
||||||
|
ca_cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(ca_subject)
|
||||||
|
.issuer_name(ca_subject)
|
||||||
|
.public_key(ca_key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - timedelta(minutes=5))
|
||||||
|
.not_valid_after(now + timedelta(days=365))
|
||||||
|
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
ca_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||||
|
ca_key_pem = ca_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
).decode("utf-8")
|
||||||
|
AgentCertificateAuthority.objects.create(
|
||||||
|
name="Agent Test CA",
|
||||||
|
cert_pem=ca_pem,
|
||||||
|
key_pem=ca_key_pem,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
agent_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
agent_cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "agent.example.test")]))
|
||||||
|
.issuer_name(ca_cert.subject)
|
||||||
|
.public_key(agent_key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - timedelta(minutes=5))
|
||||||
|
.not_valid_after(now + timedelta(days=90))
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
cert_pem = agent_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||||
|
fingerprint = agent_cert.fingerprint(hashes.SHA256()).hex()
|
||||||
|
serial = format(agent_cert.serial_number, "x")
|
||||||
|
server.agent_cert_fingerprint = fingerprint
|
||||||
|
server.agent_cert_serial = serial
|
||||||
|
server.save(update_fields=["agent_cert_fingerprint", "agent_cert_serial"])
|
||||||
|
self.agent_headers = {
|
||||||
|
"HTTP_X_KEYWARDEN_TLS_CLIENT_VERIFY": "SUCCESS",
|
||||||
|
"HTTP_X_KEYWARDEN_TLS_CLIENT_CERT": quote(cert_pem, safe=""),
|
||||||
|
"HTTP_X_KEYWARDEN_TLS_CLIENT_FINGERPRINT": fingerprint,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_ingest_logs_persists_events(self) -> None:
|
||||||
|
payload = [
|
||||||
|
{
|
||||||
|
"timestamp": "2026-04-14T10:20:30Z",
|
||||||
|
"category": "access",
|
||||||
|
"event_type": "ssh.login.success",
|
||||||
|
"source_kind": "service",
|
||||||
|
"source_name": "sshd.service",
|
||||||
|
"priority": "6",
|
||||||
|
"username": "alice",
|
||||||
|
"source_ip": "203.0.113.10",
|
||||||
|
"message": "Accepted publickey for alice from 203.0.113.10",
|
||||||
|
"fields": {"_SYSTEMD_UNIT": "sshd.service", "PRIORITY": 6},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "not-a-time",
|
||||||
|
"category": "system",
|
||||||
|
"event_type": "system",
|
||||||
|
"source_ip": "not-an-ip",
|
||||||
|
"message": "fallback timestamp test",
|
||||||
|
"fields": {"FOO": "bar"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/v1/agent/servers/{self.server.id}/logs",
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
**self.agent_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["accepted"], 2)
|
||||||
|
self.assertEqual(ServerAuditLog.objects.filter(server=self.server).count(), 2)
|
||||||
|
|
||||||
|
success_log = ServerAuditLog.objects.get(event_type="ssh.login.success")
|
||||||
|
self.assertEqual(success_log.category, "access")
|
||||||
|
self.assertEqual(success_log.username, "alice")
|
||||||
|
self.assertEqual(success_log.source_kind, "service")
|
||||||
|
self.assertEqual(success_log.source_name, "sshd.service")
|
||||||
|
self.assertEqual(success_log.source_ip, "203.0.113.10")
|
||||||
|
self.assertEqual(success_log.fields.get("PRIORITY"), "6")
|
||||||
|
|
||||||
|
fallback_log = ServerAuditLog.objects.get(message="fallback timestamp test")
|
||||||
|
self.assertIsNone(fallback_log.source_ip)
|
||||||
|
self.assertLess(abs((timezone.now() - fallback_log.event_at).total_seconds()), 10)
|
||||||
|
|
||||||
|
def test_ingest_logs_returns_404_for_unknown_server(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/agent/servers/999999/logs",
|
||||||
|
data=json.dumps([]),
|
||||||
|
content_type="application/json",
|
||||||
|
**self.agent_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_ingest_logs_requires_agent_mtls(self) -> None:
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/v1/agent/servers/{self.server.id}/logs",
|
||||||
|
data=json.dumps([]),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
def test_ingest_logs_rejects_mtls_cert_for_different_server(self) -> None:
|
||||||
|
other_server = Server.objects.create(display_name="web-02", hostname="web-02.example.test")
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/v1/agent/servers/{other_server.id}/logs",
|
||||||
|
data=json.dumps([]),
|
||||||
|
content_type="application/json",
|
||||||
|
**self.agent_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_log_config_returns_server_sources(self) -> None:
|
||||||
|
ServerLogSource.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
kind=ServerLogSource.Kind.SERVICE,
|
||||||
|
name="OpenSSH",
|
||||||
|
service_unit="ssh.service",
|
||||||
|
category_override="access",
|
||||||
|
event_type_override="ssh",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
ServerLogSource.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
kind=ServerLogSource.Kind.FILE,
|
||||||
|
name="Auth File",
|
||||||
|
file_path="/var/log/auth.log",
|
||||||
|
category_override="auth",
|
||||||
|
event_type_override="file.line",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
ServerLogSource.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
kind=ServerLogSource.Kind.JOURNAL,
|
||||||
|
name="Kernel Journal",
|
||||||
|
parser=ServerLogSource.Parser.NONE,
|
||||||
|
include_matches={"_TRANSPORT": ["kernel"]},
|
||||||
|
category_override="system",
|
||||||
|
event_type_override="kernel",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/v1/agent/servers/{self.server.id}/log-config",
|
||||||
|
**self.agent_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(len(payload), 3)
|
||||||
|
kinds = {item["kind"] for item in payload}
|
||||||
|
self.assertEqual(kinds, {"service", "file", "journal"})
|
||||||
|
for item in payload:
|
||||||
|
self.assertTrue(item["source_id"])
|
||||||
|
self.assertIn("parser", item)
|
||||||
|
self.assertIn("include_matches", item)
|
||||||
|
self.assertIn("exclude_matches", item)
|
||||||
|
|
||||||
|
def test_log_config_returns_empty_when_no_sources(self) -> None:
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/v1/agent/servers/{self.server.id}/log-config",
|
||||||
|
**self.agent_headers,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), [])
|
||||||
|
|
||||||
|
|
||||||
|
class ServerAuditViewTests(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="auditor",
|
||||||
|
email="auditor@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
self.server = Server.objects.create(display_name="db-01", hostname="db-01.example.test")
|
||||||
|
assign_perm("view_server", self.user, self.server)
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=5),
|
||||||
|
source_kind="service",
|
||||||
|
source_name="sshd.service",
|
||||||
|
category="access",
|
||||||
|
event_type="ssh.login.success",
|
||||||
|
username="alice",
|
||||||
|
source_ip="203.0.113.10",
|
||||||
|
message="Accepted publickey for alice",
|
||||||
|
)
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=4),
|
||||||
|
source_kind="service",
|
||||||
|
source_name="sshd.service",
|
||||||
|
category="access",
|
||||||
|
event_type="ssh.login.fail",
|
||||||
|
username="bob",
|
||||||
|
source_ip="198.51.100.5",
|
||||||
|
message="Failed password for bob",
|
||||||
|
)
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=3),
|
||||||
|
source_kind="file",
|
||||||
|
source_name="/var/log/auth.log",
|
||||||
|
category="network",
|
||||||
|
event_type="network",
|
||||||
|
message="Interface eth0 changed",
|
||||||
|
)
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=2),
|
||||||
|
source_kind="file",
|
||||||
|
source_name="/var/log/nginx/access.log",
|
||||||
|
category="access",
|
||||||
|
event_type="http.access",
|
||||||
|
source_ip="203.0.113.10",
|
||||||
|
message='192.0.2.40 - - [15/Apr/2026:23:57:44 +0000] "GET /api/v1/agent/servers/6/log-config HTTP/2.0" 200 682 "-"',
|
||||||
|
fields={"nginx.status": "200"},
|
||||||
|
)
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=1),
|
||||||
|
source_kind="file",
|
||||||
|
source_name="/var/log/nginx/access.log",
|
||||||
|
category="access",
|
||||||
|
event_type="http.access",
|
||||||
|
source_ip="198.51.100.5",
|
||||||
|
message='192.0.2.41 - - [15/Apr/2026:23:57:45 +0000] "GET /private HTTP/2.0" 401 245 "-"',
|
||||||
|
fields={"nginx.status": "401"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_audit_view_applies_filters(self) -> None:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("servers:audit", args=[self.server.id]),
|
||||||
|
{
|
||||||
|
"category": "access",
|
||||||
|
"event_type": "ssh.login.success",
|
||||||
|
"q": "Accepted",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
logs = list(response.context["initial_panel"]["logs"])
|
||||||
|
self.assertEqual(len(logs), 1)
|
||||||
|
self.assertEqual(logs[0].event_type, "ssh.login.success")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRouterHelpersTests(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="helper",
|
||||||
|
email="helper@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
self.server = Server.objects.create(display_name="helper-01", hostname="helper-01.example.test")
|
||||||
|
assign_perm("view_server", self.user, self.server)
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=5),
|
||||||
|
source_kind="service",
|
||||||
|
source_name="sshd.service",
|
||||||
|
category="access",
|
||||||
|
event_type="ssh.login.success",
|
||||||
|
username="alice",
|
||||||
|
source_ip="203.0.113.10",
|
||||||
|
message="Accepted publickey for alice",
|
||||||
|
)
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=4),
|
||||||
|
source_kind="service",
|
||||||
|
source_name="sshd.service",
|
||||||
|
category="access",
|
||||||
|
event_type="ssh.login.fail",
|
||||||
|
username="bob",
|
||||||
|
source_ip="198.51.100.5",
|
||||||
|
message="Failed password for bob",
|
||||||
|
)
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=3),
|
||||||
|
source_kind="file",
|
||||||
|
source_name="/var/log/auth.log",
|
||||||
|
category="network",
|
||||||
|
event_type="network",
|
||||||
|
message="Interface eth0 changed",
|
||||||
|
)
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=2),
|
||||||
|
source_kind="file",
|
||||||
|
source_name="/var/log/nginx/access.log",
|
||||||
|
category="access",
|
||||||
|
event_type="http.access",
|
||||||
|
source_ip="203.0.113.10",
|
||||||
|
message='192.0.2.40 - - [15/Apr/2026:23:57:44 +0000] "GET /api/v1/agent/servers/6/log-config HTTP/2.0" 200 682 "-"',
|
||||||
|
fields={"nginx.status": "200"},
|
||||||
|
)
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=1),
|
||||||
|
source_kind="file",
|
||||||
|
source_name="/var/log/nginx/access.log",
|
||||||
|
category="access",
|
||||||
|
event_type="http.access",
|
||||||
|
source_ip="198.51.100.5",
|
||||||
|
message='192.0.2.41 - - [15/Apr/2026:23:57:45 +0000] "GET /private HTTP/2.0" 401 245 "-"',
|
||||||
|
fields={"nginx.status": "401"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_ca_material(self):
|
||||||
|
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Keywarden Test Agent CA")])
|
||||||
|
now = datetime.utcnow()
|
||||||
|
ca_cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(subject)
|
||||||
|
.issuer_name(subject)
|
||||||
|
.public_key(ca_key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - timedelta(minutes=5))
|
||||||
|
.not_valid_after(now + timedelta(days=365))
|
||||||
|
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
ca_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||||
|
key_pem = ca_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
).decode("utf-8")
|
||||||
|
return ca_cert, ca_key, ca_pem, key_pem
|
||||||
|
|
||||||
|
def _build_csr(self, common_name: str = "agent.example.test"):
|
||||||
|
agent_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
csr = (
|
||||||
|
x509.CertificateSigningRequestBuilder()
|
||||||
|
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)]))
|
||||||
|
.sign(agent_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
return csr
|
||||||
|
|
||||||
|
def test_ip_and_truncation_helpers(self) -> None:
|
||||||
|
self.assertEqual(agent_router._normalize_ip("203.0.113.10", 4), "203.0.113.10")
|
||||||
|
self.assertEqual(agent_router._normalize_ip("2001:db8::10", 6), "2001:db8::10")
|
||||||
|
self.assertIsNone(agent_router._normalize_ip("not-ip", 4))
|
||||||
|
self.assertEqual(agent_router._normalize_ip_any(" 203.0.113.11 "), "203.0.113.11")
|
||||||
|
self.assertIsNone(agent_router._normalize_ip_any("bad-value"))
|
||||||
|
self.assertEqual(agent_router._truncate(" abc ", 3), "abc")
|
||||||
|
self.assertEqual(agent_router._truncate(None, 5, default=" xyz "), "xyz")
|
||||||
|
|
||||||
|
def test_parse_event_at_and_fields_coercion(self) -> None:
|
||||||
|
fallback = datetime(2026, 4, 17, 12, 0, 0)
|
||||||
|
parsed = agent_router._parse_event_at("2026-04-17T12:34:56Z", fallback)
|
||||||
|
self.assertEqual(parsed.isoformat(), "2026-04-17T12:34:56+00:00")
|
||||||
|
fallback_parsed = agent_router._parse_event_at("not-a-time", fallback)
|
||||||
|
self.assertTrue(timezone.is_aware(fallback_parsed))
|
||||||
|
coerced = agent_router._coerce_fields({"A": 1, "B": True, "": "skip"})
|
||||||
|
self.assertEqual(coerced, {"A": "1", "B": "True"})
|
||||||
|
self.assertEqual(agent_router._coerce_fields(None), {})
|
||||||
|
|
||||||
|
def test_source_kind_name_and_match_cleaning_helpers(self) -> None:
|
||||||
|
self.assertEqual(agent_router._normalize_source_kind("service", None, None), "service")
|
||||||
|
self.assertEqual(agent_router._normalize_source_kind(None, "sshd.service", None), "service")
|
||||||
|
self.assertEqual(agent_router._normalize_source_kind(None, None, {"_TRANSPORT": "journal"}), "journal")
|
||||||
|
self.assertEqual(agent_router._normalize_source_kind(None, None, {"file_path": "/var/log/auth.log"}), "file")
|
||||||
|
self.assertEqual(agent_router._normalize_source_kind(None, None, None), "")
|
||||||
|
|
||||||
|
self.assertEqual(agent_router._normalize_source_name(" sshd ", "x", {}), "sshd")
|
||||||
|
self.assertEqual(agent_router._normalize_source_name(None, "sudo.service", {}), "sudo.service")
|
||||||
|
self.assertEqual(agent_router._normalize_source_name(None, None, {"_SYSTEMD_UNIT": "ssh.service"}), "ssh.service")
|
||||||
|
self.assertEqual(agent_router._normalize_source_name(None, None, {"file_path": "/var/log/auth.log"}), "/var/log/auth.log")
|
||||||
|
|
||||||
|
clean = agent_router._clean_source_matches({"_TRANSPORT": [" kernel ", ""], "UNIT": " sshd.service "})
|
||||||
|
self.assertEqual(clean, {"_TRANSPORT": ["kernel"], "UNIT": ["sshd.service"]})
|
||||||
|
self.assertEqual(agent_router._clean_source_matches(None), {})
|
||||||
|
|
||||||
|
def test_default_category_and_event_type_helpers(self) -> None:
|
||||||
|
journal = SimpleNamespace(
|
||||||
|
kind=ServerLogSource.Kind.JOURNAL,
|
||||||
|
service_unit="",
|
||||||
|
include_matches={"_TRANSPORT": ["kernel"]},
|
||||||
|
)
|
||||||
|
service_ssh = SimpleNamespace(
|
||||||
|
kind=ServerLogSource.Kind.SERVICE,
|
||||||
|
service_unit="sshd.service",
|
||||||
|
include_matches={},
|
||||||
|
)
|
||||||
|
service_sudo = SimpleNamespace(
|
||||||
|
kind=ServerLogSource.Kind.SERVICE,
|
||||||
|
service_unit="sudo.service",
|
||||||
|
include_matches={},
|
||||||
|
)
|
||||||
|
file_source = SimpleNamespace(
|
||||||
|
kind=ServerLogSource.Kind.FILE,
|
||||||
|
service_unit="",
|
||||||
|
include_matches={},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(agent_router._default_category_for_source(journal), "system")
|
||||||
|
self.assertEqual(agent_router._default_category_for_source(service_ssh), "access")
|
||||||
|
self.assertEqual(agent_router._default_category_for_source(service_sudo), "auth")
|
||||||
|
self.assertEqual(agent_router._default_event_type_for_source(journal), "kernel")
|
||||||
|
self.assertEqual(agent_router._default_event_type_for_source(service_ssh), "ssh")
|
||||||
|
self.assertEqual(agent_router._default_event_type_for_source(service_sudo), "auth")
|
||||||
|
self.assertEqual(agent_router._default_event_type_for_source(file_source), "file.line")
|
||||||
|
|
||||||
|
def test_agent_access_guards_and_token_generation(self) -> None:
|
||||||
|
with self.assertRaises(HttpError) as unauth:
|
||||||
|
agent_router._require_agent_access(SimpleNamespace(auth=None), self.server.id)
|
||||||
|
self.assertEqual(unauth.exception.status_code, 401)
|
||||||
|
|
||||||
|
with self.assertRaises(HttpError) as forbidden:
|
||||||
|
agent_router._require_agent_access(
|
||||||
|
SimpleNamespace(auth=agent_router.AgentPrincipal(server_id=999, mode="mtls")),
|
||||||
|
self.server.id,
|
||||||
|
)
|
||||||
|
self.assertEqual(forbidden.exception.status_code, 403)
|
||||||
|
|
||||||
|
agent_router._require_agent_access(
|
||||||
|
SimpleNamespace(auth=agent_router.AgentPrincipal(server_id=self.server.id, mode="mtls")),
|
||||||
|
self.server.id,
|
||||||
|
)
|
||||||
|
agent_router._require_agent_access(
|
||||||
|
SimpleNamespace(auth=agent_router.AgentPrincipal(server_id=None, mode="global-token")),
|
||||||
|
self.server.id,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(agent_router._issue_agent_api_token(), agent_router._issue_agent_api_token())
|
||||||
|
|
||||||
|
def test_server_lookup_key_map_and_account_sync_helpers(self) -> None:
|
||||||
|
found = agent_router._get_server_or_404(self.server.id)
|
||||||
|
self.assertEqual(found.id, self.server.id)
|
||||||
|
with self.assertRaises(HttpError) as not_found:
|
||||||
|
agent_router._get_server_or_404(999999)
|
||||||
|
self.assertEqual(not_found.exception.status_code, 404)
|
||||||
|
|
||||||
|
key_active = SSHKey.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
name="active",
|
||||||
|
public_key="ssh-ed25519 AAAA",
|
||||||
|
key_type="ssh-ed25519",
|
||||||
|
fingerprint="fp-active",
|
||||||
|
is_active=True,
|
||||||
|
revoked_at=None,
|
||||||
|
)
|
||||||
|
SSHKey.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
name="revoked",
|
||||||
|
public_key="ssh-ed25519 BBBB",
|
||||||
|
key_type="ssh-ed25519",
|
||||||
|
fingerprint="fp-revoked",
|
||||||
|
is_active=True,
|
||||||
|
revoked_at=timezone.now(),
|
||||||
|
)
|
||||||
|
key_map = agent_router._key_map_for_users([self.user])
|
||||||
|
self.assertIn(self.user.id, key_map)
|
||||||
|
self.assertEqual([item.id for item in key_map[self.user.id]], [key_active.id])
|
||||||
|
|
||||||
|
payload = [agent_router.AccountSyncIn(user_id=self.user.id, system_username="helper_1", present=True)]
|
||||||
|
agent_router._update_server_accounts(self.server, payload)
|
||||||
|
account = self.server.accounts.get(user=self.user)
|
||||||
|
self.assertEqual(account.system_username, "helper_1")
|
||||||
|
self.assertTrue(account.is_present)
|
||||||
|
|
||||||
|
def test_csr_and_agent_ca_helpers(self) -> None:
|
||||||
|
with self.assertRaises(HttpError) as bad_csr:
|
||||||
|
agent_router._load_csr("not a csr")
|
||||||
|
self.assertEqual(bad_csr.exception.status_code, 422)
|
||||||
|
|
||||||
|
with patch("keywarden.api.routers.agent.x509.load_pem_x509_csr", return_value=SimpleNamespace(is_signature_valid=False)):
|
||||||
|
with self.assertRaises(HttpError) as bad_sig:
|
||||||
|
agent_router._load_csr("dummy")
|
||||||
|
self.assertEqual(bad_sig.exception.status_code, 422)
|
||||||
|
|
||||||
|
ca_cert, ca_key, ca_pem, key_pem = self._build_ca_material()
|
||||||
|
ca_record = agent_router.AgentCertificateAuthority.objects.create(
|
||||||
|
name="Test Agent CA",
|
||||||
|
cert_pem=ca_pem,
|
||||||
|
key_pem=key_pem,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
loaded_cert, loaded_key, loaded_pem = agent_router._load_agent_ca()
|
||||||
|
self.assertEqual(loaded_pem, ca_record.cert_pem)
|
||||||
|
self.assertIsNotNone(loaded_cert)
|
||||||
|
self.assertIsNotNone(loaded_key)
|
||||||
|
|
||||||
|
csr = self._build_csr("agent-1.example.test")
|
||||||
|
with patch("keywarden.api.routers.agent._load_agent_ca", return_value=(ca_cert, ca_key, ca_pem)):
|
||||||
|
cert_pem, returned_ca_pem, fingerprint, serial = agent_router._issue_client_cert(
|
||||||
|
csr=csr,
|
||||||
|
host="agent-1.example.test",
|
||||||
|
server_id=self.server.id,
|
||||||
|
)
|
||||||
|
self.assertIn("BEGIN CERTIFICATE", cert_pem)
|
||||||
|
self.assertEqual(returned_ca_pem, ca_pem)
|
||||||
|
self.assertTrue(fingerprint)
|
||||||
|
self.assertTrue(serial)
|
||||||
|
|
||||||
|
def test_enroll_agent_rotates_existing_server_when_server_id_is_provided(self) -> None:
|
||||||
|
ca_cert, _ca_key, ca_pem, key_pem = self._build_ca_material()
|
||||||
|
agent_router.AgentCertificateAuthority.objects.create(
|
||||||
|
name="Enroll CA",
|
||||||
|
cert_pem=ca_pem,
|
||||||
|
key_pem=key_pem,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
token = EnrollmentToken.objects.create(token="rotate-token")
|
||||||
|
original_server_count = Server.objects.count()
|
||||||
|
self.server.agent_cert_fingerprint = "deadbeef"
|
||||||
|
self.server.agent_cert_serial = "1"
|
||||||
|
self.server.save(update_fields=["agent_cert_fingerprint", "agent_cert_serial"])
|
||||||
|
|
||||||
|
csr = self._build_csr("rotate-agent.example.test")
|
||||||
|
payload = {
|
||||||
|
"token": token.token,
|
||||||
|
"csr_pem": csr.public_bytes(serialization.Encoding.PEM).decode("utf-8"),
|
||||||
|
"server_id": str(self.server.id),
|
||||||
|
"host": self.server.hostname,
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/agent/enroll",
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual(body["server_id"], str(self.server.id))
|
||||||
|
self.assertIn("BEGIN CERTIFICATE", body["client_cert_pem"])
|
||||||
|
self.assertIn("BEGIN CERTIFICATE", body["ca_cert_pem"])
|
||||||
|
self.assertTrue(body["agent_api_token"])
|
||||||
|
self.assertEqual(Server.objects.count(), original_server_count)
|
||||||
|
|
||||||
|
self.server.refresh_from_db()
|
||||||
|
self.assertNotEqual(self.server.agent_cert_fingerprint, "deadbeef")
|
||||||
|
self.assertNotEqual(self.server.agent_cert_serial, "1")
|
||||||
|
self.assertIsNotNone(self.server.agent_enrolled_at)
|
||||||
|
token.refresh_from_db()
|
||||||
|
self.assertEqual(token.server_id, self.server.id)
|
||||||
|
self.assertIsNotNone(token.used_at)
|
||||||
|
|
||||||
|
def test_enroll_agent_rejects_invalid_server_id(self) -> None:
|
||||||
|
token = EnrollmentToken.objects.create(token="rotate-token-bad")
|
||||||
|
csr = self._build_csr("rotate-agent.example.test")
|
||||||
|
payload = {
|
||||||
|
"token": token.token,
|
||||||
|
"csr_pem": csr.public_bytes(serialization.Encoding.PEM).decode("utf-8"),
|
||||||
|
"server_id": "not-a-number",
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/agent/enroll",
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
self.assertIn("server_id must be numeric", response.content.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
class KeysModelHelpersTests(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="keys-user",
|
||||||
|
email="keys-user@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
self.server = Server.objects.create(display_name="keys-server", hostname="keys-server.example.test")
|
||||||
|
assign_perm("view_server", self.user, self.server)
|
||||||
|
now = timezone.now()
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=5),
|
||||||
|
source_kind="service",
|
||||||
|
source_name="sshd.service",
|
||||||
|
category="access",
|
||||||
|
event_type="ssh.login.success",
|
||||||
|
username="alice",
|
||||||
|
source_ip="203.0.113.10",
|
||||||
|
message="Accepted publickey for alice",
|
||||||
|
)
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=4),
|
||||||
|
source_kind="service",
|
||||||
|
source_name="sshd.service",
|
||||||
|
category="access",
|
||||||
|
event_type="ssh.login.fail",
|
||||||
|
username="bob",
|
||||||
|
source_ip="198.51.100.5",
|
||||||
|
message="Failed password for bob",
|
||||||
|
)
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=3),
|
||||||
|
source_kind="file",
|
||||||
|
source_name="/var/log/auth.log",
|
||||||
|
category="network",
|
||||||
|
event_type="network",
|
||||||
|
message="Interface eth0 changed",
|
||||||
|
)
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=2),
|
||||||
|
source_kind="file",
|
||||||
|
source_name="/var/log/nginx/access.log",
|
||||||
|
category="access",
|
||||||
|
event_type="http.access",
|
||||||
|
source_ip="203.0.113.10",
|
||||||
|
message='192.0.2.40 - - [15/Apr/2026:23:57:44 +0000] "GET /api/v1/agent/servers/6/log-config HTTP/2.0" 200 682 "-"',
|
||||||
|
fields={"nginx.status": "200"},
|
||||||
|
)
|
||||||
|
ServerAuditLog.objects.create(
|
||||||
|
server=self.server,
|
||||||
|
event_at=now - timedelta(minutes=1),
|
||||||
|
source_kind="file",
|
||||||
|
source_name="/var/log/nginx/access.log",
|
||||||
|
category="access",
|
||||||
|
event_type="http.access",
|
||||||
|
source_ip="198.51.100.5",
|
||||||
|
message='192.0.2.41 - - [15/Apr/2026:23:57:45 +0000] "GET /private HTTP/2.0" 401 245 "-"',
|
||||||
|
fields={"nginx.status": "401"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parse_public_key_valid_and_invalid(self) -> None:
|
||||||
|
key_type, key_b64, fingerprint = parse_public_key("ssh-ed25519 AAAA comment")
|
||||||
|
self.assertEqual(key_type, "ssh-ed25519")
|
||||||
|
self.assertEqual(key_b64, "AAAA")
|
||||||
|
self.assertTrue(fingerprint.startswith("SHA256:"))
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
parse_public_key("invalid-key")
|
||||||
|
|
||||||
|
def test_sshkey_set_public_key_and_revoke(self) -> None:
|
||||||
|
key = SSHKey.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
name="laptop",
|
||||||
|
public_key="ssh-ed25519 AAAA",
|
||||||
|
key_type="ssh-ed25519",
|
||||||
|
fingerprint="fp-laptop",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
key.set_public_key("ssh-ed25519 AAAA comment-to-drop")
|
||||||
|
self.assertEqual(key.public_key, "ssh-ed25519 AAAA")
|
||||||
|
now = timezone.now()
|
||||||
|
cert = SSHCertificate.objects.create(
|
||||||
|
key=key,
|
||||||
|
user=self.user,
|
||||||
|
certificate="ssh-ed25519-cert-v01 AAAA",
|
||||||
|
serial=42,
|
||||||
|
principals=["keys_user"],
|
||||||
|
valid_after=now - timedelta(minutes=1),
|
||||||
|
valid_before=now + timedelta(days=1),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
key.revoke()
|
||||||
|
key.save(update_fields=["is_active", "revoked_at"])
|
||||||
|
key.refresh_from_db()
|
||||||
|
cert.refresh_from_db()
|
||||||
|
self.assertFalse(key.is_active)
|
||||||
|
self.assertIsNotNone(key.revoked_at)
|
||||||
|
self.assertFalse(cert.is_active)
|
||||||
|
self.assertIsNotNone(cert.revoked_at)
|
||||||
|
|
||||||
|
def test_ssh_certificate_authority_ensure_material_paths(self) -> None:
|
||||||
|
ca = SSHCertificateAuthority(
|
||||||
|
name="Existing CA",
|
||||||
|
public_key="ssh-ed25519 AAAA",
|
||||||
|
private_key="-----BEGIN OPENSSH PRIVATE KEY-----\nPRIVATE\n-----END OPENSSH PRIVATE KEY-----\n",
|
||||||
|
fingerprint="",
|
||||||
|
)
|
||||||
|
ca.ensure_material()
|
||||||
|
self.assertTrue(ca.fingerprint.startswith("SHA256:"))
|
||||||
|
self.assertTrue(ca.private_key.startswith("file://"))
|
||||||
|
|
||||||
|
generated = SSHCertificateAuthority(name="Generated CA")
|
||||||
|
|
||||||
|
def fake_run(cmd, check, capture_output):
|
||||||
|
key_path = cmd[cmd.index("-f") + 1]
|
||||||
|
with open(key_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("PRIVATE-KEY\n")
|
||||||
|
with open(key_path + ".pub", "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("ssh-ed25519 AAAA generated-ca\n")
|
||||||
|
return SimpleNamespace(returncode=0)
|
||||||
|
|
||||||
|
with patch("apps.keys.models.subprocess.run", side_effect=fake_run):
|
||||||
|
generated.ensure_material()
|
||||||
|
self.assertTrue(generated.private_key)
|
||||||
|
self.assertEqual(generated.public_key, "ssh-ed25519 AAAA generated-ca")
|
||||||
|
self.assertTrue(generated.fingerprint.startswith("SHA256:"))
|
||||||
|
|
||||||
|
def test_ssh_certificate_revoke(self) -> None:
|
||||||
|
key = SSHKey.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
name="desktop",
|
||||||
|
public_key="ssh-ed25519 AAAA",
|
||||||
|
key_type="ssh-ed25519",
|
||||||
|
fingerprint="fp-desktop",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
cert = SSHCertificate.objects.create(
|
||||||
|
key=key,
|
||||||
|
user=self.user,
|
||||||
|
certificate="ssh-ed25519-cert-v01 AAAA",
|
||||||
|
serial=77,
|
||||||
|
principals=["keys_user"],
|
||||||
|
valid_after=timezone.now() - timedelta(minutes=1),
|
||||||
|
valid_before=timezone.now() + timedelta(days=1),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
cert.revoke()
|
||||||
|
self.assertFalse(cert.is_active)
|
||||||
|
self.assertIsNotNone(cert.revoked_at)
|
||||||
|
|
||||||
|
def test_audit_panel_view_supports_source_filters(self) -> None:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("servers:audit_panel", args=[self.server.id]),
|
||||||
|
{
|
||||||
|
"source_kind": "file",
|
||||||
|
"source_name": "/var/log/auth.log",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "/var/log/auth.log")
|
||||||
|
self.assertContains(response, "Interface eth0 changed")
|
||||||
|
self.assertNotContains(response, "Accepted publickey for alice")
|
||||||
|
|
||||||
|
def test_audit_view_exposes_chart_context(self) -> None:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("servers:audit", args=[self.server.id]),
|
||||||
|
{"view": "charts"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.context["audit_subpage"], "charts")
|
||||||
|
|
||||||
|
charts = response.context["audit_charts"]
|
||||||
|
login = charts["login_attempts"]
|
||||||
|
self.assertEqual(login["total_attempts"], 2)
|
||||||
|
self.assertEqual(login["failed_attempts"], 1)
|
||||||
|
self.assertEqual(login["successful_attempts"], 1)
|
||||||
|
self.assertEqual(login["failure_rate_pct"], 50)
|
||||||
|
self.assertEqual(login["chart"]["drilldowns"][0], "view=logs&login_outcome=attempts")
|
||||||
|
|
||||||
|
status_labels = charts["http_status_codes"]["chart"]["labels"]
|
||||||
|
status_counts = charts["http_status_codes"]["chart"]["values"]
|
||||||
|
status_map = dict(zip(status_labels, status_counts))
|
||||||
|
self.assertEqual(status_map["401"], 1)
|
||||||
|
self.assertNotIn("200", status_map)
|
||||||
|
self.assertEqual(charts["http_status_codes"]["implicit_total"], 1)
|
||||||
|
|
||||||
|
def test_audit_view_drilldown_filters_logs_for_http_status(self) -> None:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("servers:audit", args=[self.server.id]),
|
||||||
|
{"view": "logs", "http_status": "401"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
logs = list(response.context["initial_panel"]["logs"])
|
||||||
|
self.assertEqual(len(logs), 1)
|
||||||
|
self.assertEqual(logs[0].event_type, "http.access")
|
||||||
|
self.assertIn(" 401 ", logs[0].message)
|
||||||
|
|
||||||
|
def test_audit_view_drilldown_filters_logs_for_login_outcome(self) -> None:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("servers:audit", args=[self.server.id]),
|
||||||
|
{"view": "logs", "login_outcome": "success"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
logs = list(response.context["initial_panel"]["logs"])
|
||||||
|
self.assertEqual(len(logs), 1)
|
||||||
|
self.assertEqual(logs[0].event_type, "ssh.login.success")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from apps.core.secret_files import parse_secret_ref
|
||||||
|
from apps.keys.models import SSHCertificateAuthority
|
||||||
|
from apps.servers.models import AgentCertificateAuthority
|
||||||
|
|
||||||
|
|
||||||
|
class CASecretFileRecoveryTests(TestCase):
|
||||||
|
def test_user_ca_recovers_when_private_key_file_is_missing(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
missing_path = os.path.join(tmpdir, "missing-user-ca-key.pem")
|
||||||
|
with override_settings(KEYWARDEN_CA_KEY_DIR=tmpdir):
|
||||||
|
ca = SSHCertificateAuthority.objects.create(
|
||||||
|
name="User CA Recovery",
|
||||||
|
public_key="ssh-ed25519 AAAAinvalid",
|
||||||
|
private_key=f"file://{missing_path}",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
ca.ensure_material()
|
||||||
|
ca.save()
|
||||||
|
|
||||||
|
self.assertTrue(ca.public_key.startswith("ssh-ed25519 "))
|
||||||
|
self.assertTrue(ca.fingerprint.startswith("SHA256:"))
|
||||||
|
key_path = parse_secret_ref(ca.private_key)
|
||||||
|
self.assertTrue(key_path)
|
||||||
|
self.assertTrue(os.path.exists(key_path))
|
||||||
|
self.assertIn("BEGIN OPENSSH PRIVATE KEY", ca.get_private_key())
|
||||||
|
|
||||||
|
def test_agent_ca_recovers_when_private_key_file_is_missing(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
missing_path = os.path.join(tmpdir, "missing-agent-ca-key.pem")
|
||||||
|
with override_settings(KEYWARDEN_CA_KEY_DIR=tmpdir):
|
||||||
|
ca = AgentCertificateAuthority.objects.create(
|
||||||
|
name="Agent CA Recovery",
|
||||||
|
cert_pem="",
|
||||||
|
key_pem=f"file://{missing_path}",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
ca.ensure_material()
|
||||||
|
ca.save()
|
||||||
|
|
||||||
|
self.assertIn("BEGIN CERTIFICATE", ca.cert_pem)
|
||||||
|
self.assertTrue(ca.fingerprint)
|
||||||
|
self.assertTrue(ca.serial)
|
||||||
|
key_path = parse_secret_ref(ca.key_pem)
|
||||||
|
self.assertTrue(key_path)
|
||||||
|
self.assertTrue(os.path.exists(key_path))
|
||||||
|
self.assertIn("BEGIN RSA PRIVATE KEY", ca.get_key_pem())
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from urllib.parse import quote
|
||||||
|
from unittest import IsolatedAsyncioTestCase
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import SimpleTestCase, TestCase, override_settings
|
||||||
|
|
||||||
|
from apps.servers.consumers import (
|
||||||
|
ShellConsumer,
|
||||||
|
_build_ssh_command,
|
||||||
|
_format_ssh_host,
|
||||||
|
_generate_session_keypair,
|
||||||
|
_is_truthy,
|
||||||
|
_summarize_probe_output,
|
||||||
|
_truncate_lines,
|
||||||
|
)
|
||||||
|
from apps.keys import certificates as key_certificates
|
||||||
|
from apps.keys.utils import render_system_username, sanitize_username
|
||||||
|
from apps.servers.models import AgentCertificateAuthority, Server
|
||||||
|
from keywarden.api import security as api_security
|
||||||
|
|
||||||
|
|
||||||
|
class ShellSSHCommandTests(SimpleTestCase):
|
||||||
|
def test_build_command_uses_default_remote_shell(self):
|
||||||
|
command = _build_ssh_command(
|
||||||
|
key_path="/tmp/session_key",
|
||||||
|
cert_path="/tmp/session_key-cert.pub",
|
||||||
|
username="alice",
|
||||||
|
host="server.example.test",
|
||||||
|
)
|
||||||
|
expected = str(getattr(settings, "KEYWARDEN_SHELL_REMOTE_COMMAND", "/bin/bash")).strip()
|
||||||
|
self.assertEqual(command[-1], expected)
|
||||||
|
|
||||||
|
@override_settings(KEYWARDEN_SHELL_REMOTE_COMMAND="/bin/bash -li")
|
||||||
|
def test_build_command_appends_configured_remote_shell(self):
|
||||||
|
command = _build_ssh_command(
|
||||||
|
key_path="/tmp/session_key",
|
||||||
|
cert_path="/tmp/session_key-cert.pub",
|
||||||
|
username="alice",
|
||||||
|
host="server.example.test",
|
||||||
|
)
|
||||||
|
self.assertEqual(command[-2:], ["/bin/bash", "-li"])
|
||||||
|
self.assertIn("alice@server.example.test", command)
|
||||||
|
|
||||||
|
@override_settings(KEYWARDEN_SHELL_REMOTE_COMMAND="")
|
||||||
|
def test_build_command_can_disable_explicit_remote_shell(self):
|
||||||
|
command = _build_ssh_command(
|
||||||
|
key_path="/tmp/session_key",
|
||||||
|
cert_path="/tmp/session_key-cert.pub",
|
||||||
|
username="alice",
|
||||||
|
host="server.example.test",
|
||||||
|
)
|
||||||
|
self.assertEqual(command[-1], "alice@server.example.test")
|
||||||
|
|
||||||
|
def test_build_command_can_disable_tty_and_set_timeout(self):
|
||||||
|
command = _build_ssh_command(
|
||||||
|
key_path="/tmp/session_key",
|
||||||
|
cert_path="/tmp/session_key-cert.pub",
|
||||||
|
username="alice",
|
||||||
|
host="server.example.test",
|
||||||
|
remote_command="true",
|
||||||
|
force_tty=False,
|
||||||
|
connect_timeout=8,
|
||||||
|
)
|
||||||
|
self.assertNotIn("-tt", command)
|
||||||
|
self.assertIn("ConnectTimeout=8", command)
|
||||||
|
self.assertEqual(command[-1], "true")
|
||||||
|
|
||||||
|
def test_build_command_enforces_host_key_verification(self):
|
||||||
|
command = _build_ssh_command(
|
||||||
|
key_path="/tmp/session_key",
|
||||||
|
cert_path="/tmp/session_key-cert.pub",
|
||||||
|
username="alice",
|
||||||
|
host="server.example.test",
|
||||||
|
)
|
||||||
|
self.assertIn("StrictHostKeyChecking=accept-new", command)
|
||||||
|
self.assertIn("UpdateHostKeys=yes", command)
|
||||||
|
self.assertNotIn("StrictHostKeyChecking=no", command)
|
||||||
|
self.assertNotIn("UserKnownHostsFile=/dev/null", command)
|
||||||
|
self.assertNotIn("GlobalKnownHostsFile=/dev/null", command)
|
||||||
|
|
||||||
|
@override_settings(KEYWARDEN_SHELL_STRICT_HOST_KEY_CHECKING="yes")
|
||||||
|
def test_build_command_allows_strict_host_key_policy_override(self):
|
||||||
|
command = _build_ssh_command(
|
||||||
|
key_path="/tmp/session_key",
|
||||||
|
cert_path="/tmp/session_key-cert.pub",
|
||||||
|
username="alice",
|
||||||
|
host="server.example.test",
|
||||||
|
)
|
||||||
|
self.assertIn("StrictHostKeyChecking=yes", command)
|
||||||
|
|
||||||
|
@override_settings(KEYWARDEN_SHELL_REMOTE_COMMAND='"/bin/bash -li')
|
||||||
|
def test_build_command_tolerates_invalid_shell_quoting(self):
|
||||||
|
command = _build_ssh_command(
|
||||||
|
key_path="/tmp/session_key",
|
||||||
|
cert_path="/tmp/session_key-cert.pub",
|
||||||
|
username="alice",
|
||||||
|
host="server.example.test",
|
||||||
|
)
|
||||||
|
self.assertEqual(command[-1], '"/bin/bash -li')
|
||||||
|
|
||||||
|
|
||||||
|
class ShellHelpersTests(SimpleTestCase):
|
||||||
|
def test_format_ssh_host_wraps_ipv6(self):
|
||||||
|
self.assertEqual(_format_ssh_host("2001:db8::1"), "[2001:db8::1]")
|
||||||
|
self.assertEqual(_format_ssh_host("[2001:db8::1]"), "[2001:db8::1]")
|
||||||
|
self.assertEqual(_format_ssh_host("server.example.test"), "server.example.test")
|
||||||
|
|
||||||
|
def test_is_truthy_supports_common_values(self):
|
||||||
|
self.assertTrue(_is_truthy(True))
|
||||||
|
self.assertTrue(_is_truthy("YES"))
|
||||||
|
self.assertTrue(_is_truthy("1"))
|
||||||
|
self.assertFalse(_is_truthy(False))
|
||||||
|
self.assertFalse(_is_truthy(None))
|
||||||
|
self.assertFalse(_is_truthy("off"))
|
||||||
|
|
||||||
|
def test_truncate_lines_limits_output(self):
|
||||||
|
text = "\n".join([f"line-{idx}" for idx in range(20)])
|
||||||
|
clipped = _truncate_lines(text, max_lines=6, max_chars=2000)
|
||||||
|
self.assertIn("... (middle omitted) ...", clipped)
|
||||||
|
self.assertIn("line-0", clipped)
|
||||||
|
self.assertIn("line-19", clipped)
|
||||||
|
|
||||||
|
def test_summarize_probe_output_extracts_highlights(self):
|
||||||
|
raw = (
|
||||||
|
"debug: Authenticating to host\n"
|
||||||
|
"Permission denied (publickey).\n"
|
||||||
|
"certificate invalid: bad signature\n"
|
||||||
|
"no such user\n"
|
||||||
|
"tail marker\n"
|
||||||
|
)
|
||||||
|
summary = _summarize_probe_output(raw)
|
||||||
|
self.assertIn("Detected cause:", summary)
|
||||||
|
self.assertIn("SSH authentication was denied", summary)
|
||||||
|
self.assertIn("SSH certificate was rejected", summary)
|
||||||
|
self.assertIn("Remote user account does not exist", summary)
|
||||||
|
self.assertIn("Probe tail:", summary)
|
||||||
|
|
||||||
|
def test_generate_session_keypair_creates_cert_material(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
ca = SimpleNamespace(
|
||||||
|
private_key="ca-private",
|
||||||
|
public_key="ca-public",
|
||||||
|
get_private_key=lambda: "ca-private",
|
||||||
|
)
|
||||||
|
user = SimpleNamespace(id=7)
|
||||||
|
|
||||||
|
def fake_run(cmd, check, capture_output):
|
||||||
|
key_path = cmd[cmd.index("-f") + 1]
|
||||||
|
with open(key_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("PRIVATE\n")
|
||||||
|
with open(key_path + ".pub", "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("ssh-ed25519 AAAATEST comment\n")
|
||||||
|
return SimpleNamespace(returncode=0)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("apps.servers.consumers.get_active_ca", return_value=ca),
|
||||||
|
patch("apps.servers.consumers._sign_public_key", return_value="ssh-ed25519-cert-v01 AAAACERT"),
|
||||||
|
patch("apps.servers.consumers.subprocess.run", side_effect=fake_run),
|
||||||
|
patch("apps.servers.consumers.secrets.randbits", return_value=42),
|
||||||
|
):
|
||||||
|
key_path, cert_path = _generate_session_keypair(tmpdir, user, "alice")
|
||||||
|
|
||||||
|
with open(cert_path, "r", encoding="utf-8") as handle:
|
||||||
|
cert_body = handle.read()
|
||||||
|
self.assertTrue(key_path.endswith("session_key"))
|
||||||
|
self.assertTrue(cert_path.endswith("session_key-cert.pub"))
|
||||||
|
self.assertIn("AAAACERT", cert_body)
|
||||||
|
|
||||||
|
def test_generate_session_keypair_raises_when_ssh_keygen_missing(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
ca = SimpleNamespace(
|
||||||
|
private_key="ca-private",
|
||||||
|
public_key="ca-public",
|
||||||
|
get_private_key=lambda: "ca-private",
|
||||||
|
)
|
||||||
|
user = SimpleNamespace(id=8)
|
||||||
|
with (
|
||||||
|
patch("apps.servers.consumers.get_active_ca", return_value=ca),
|
||||||
|
patch("apps.servers.consumers.subprocess.run", side_effect=FileNotFoundError("missing")),
|
||||||
|
):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
_generate_session_keypair(tmpdir, user, "alice")
|
||||||
|
|
||||||
|
def test_generate_session_keypair_raises_on_ssh_keygen_error(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
ca = SimpleNamespace(
|
||||||
|
private_key="ca-private",
|
||||||
|
public_key="ca-public",
|
||||||
|
get_private_key=lambda: "ca-private",
|
||||||
|
)
|
||||||
|
user = SimpleNamespace(id=9)
|
||||||
|
called = subprocess.CalledProcessError(1, ["ssh-keygen"], stderr=b"boom")
|
||||||
|
with (
|
||||||
|
patch("apps.servers.consumers.get_active_ca", return_value=ca),
|
||||||
|
patch("apps.servers.consumers.subprocess.run", side_effect=called),
|
||||||
|
):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
_generate_session_keypair(tmpdir, user, "alice")
|
||||||
|
|
||||||
|
|
||||||
|
class ShellConsumerAsyncTests(IsolatedAsyncioTestCase):
|
||||||
|
async def test_connect_rejects_unauthenticated_user(self):
|
||||||
|
consumer = ShellConsumer()
|
||||||
|
consumer.scope = {"url_route": {"kwargs": {"server_id": "1"}}}
|
||||||
|
consumer.close = AsyncMock()
|
||||||
|
await consumer.connect()
|
||||||
|
consumer.close.assert_awaited_once_with(code=4401)
|
||||||
|
|
||||||
|
async def test_connect_rejects_missing_server_id(self):
|
||||||
|
consumer = ShellConsumer()
|
||||||
|
consumer.scope = {"user": SimpleNamespace(is_authenticated=True), "url_route": {"kwargs": {}}}
|
||||||
|
consumer.close = AsyncMock()
|
||||||
|
await consumer.connect()
|
||||||
|
consumer.close.assert_awaited_once_with(code=4400)
|
||||||
|
|
||||||
|
async def test_connect_accepts_and_starts_session(self):
|
||||||
|
user = SimpleNamespace(is_authenticated=True, id=5, username="alice")
|
||||||
|
server = SimpleNamespace(id=12, hostname="server.example.test", ipv4=None, ipv6=None)
|
||||||
|
consumer = ShellConsumer()
|
||||||
|
consumer.scope = {"user": user, "url_route": {"kwargs": {"server_id": "12"}}, "path": "/ws/servers/12/shell/"}
|
||||||
|
consumer.accept = AsyncMock()
|
||||||
|
consumer.send = AsyncMock()
|
||||||
|
consumer.close = AsyncMock()
|
||||||
|
consumer._get_server = AsyncMock(return_value=server)
|
||||||
|
consumer._can_shell = AsyncMock(return_value=True)
|
||||||
|
consumer._get_system_username = AsyncMock(return_value="alice_5")
|
||||||
|
consumer._audit_websocket_event = AsyncMock()
|
||||||
|
consumer._start_ssh = AsyncMock()
|
||||||
|
|
||||||
|
await consumer.connect()
|
||||||
|
|
||||||
|
consumer.accept.assert_awaited_once()
|
||||||
|
consumer._start_ssh.assert_awaited_once_with(user)
|
||||||
|
consumer.send.assert_any_await(text_data="Connecting...\r\n")
|
||||||
|
self.assertEqual(consumer.server_id, 12)
|
||||||
|
self.assertEqual(consumer.system_username, "alice_5")
|
||||||
|
self.assertEqual(consumer.shell_target, "server.example.test")
|
||||||
|
|
||||||
|
async def test_connect_handles_start_ssh_failure(self):
|
||||||
|
user = SimpleNamespace(is_authenticated=True, id=5, username="alice")
|
||||||
|
server = SimpleNamespace(id=12, hostname="server.example.test", ipv4=None, ipv6=None)
|
||||||
|
consumer = ShellConsumer()
|
||||||
|
consumer.scope = {"user": user, "url_route": {"kwargs": {"server_id": "12"}}, "path": "/ws/servers/12/shell/"}
|
||||||
|
consumer.accept = AsyncMock()
|
||||||
|
consumer.send = AsyncMock()
|
||||||
|
consumer.close = AsyncMock()
|
||||||
|
consumer._get_server = AsyncMock(return_value=server)
|
||||||
|
consumer._can_shell = AsyncMock(return_value=True)
|
||||||
|
consumer._get_system_username = AsyncMock(return_value="alice_5")
|
||||||
|
consumer._audit_websocket_event = AsyncMock()
|
||||||
|
consumer._start_ssh = AsyncMock(side_effect=RuntimeError("boom"))
|
||||||
|
|
||||||
|
await consumer.connect()
|
||||||
|
|
||||||
|
consumer.send.assert_any_await(text_data="Connection failed.\r\n")
|
||||||
|
consumer.close.assert_awaited()
|
||||||
|
|
||||||
|
async def test_receive_forwards_payload_to_ssh_stdin(self):
|
||||||
|
stdin = SimpleNamespace(write=Mock(), drain=AsyncMock())
|
||||||
|
consumer = ShellConsumer()
|
||||||
|
consumer.proc = SimpleNamespace(stdin=stdin)
|
||||||
|
await consumer.receive(text_data="echo hi")
|
||||||
|
stdin.write.assert_called_once()
|
||||||
|
stdin.drain.assert_awaited_once()
|
||||||
|
|
||||||
|
async def test_disconnect_terminates_process_and_cleans_tempdir(self):
|
||||||
|
user = SimpleNamespace(is_authenticated=True)
|
||||||
|
reader_task = SimpleNamespace(cancel=Mock())
|
||||||
|
proc = SimpleNamespace(returncode=None, terminate=Mock(), wait=AsyncMock(return_value=0), kill=Mock())
|
||||||
|
consumer = ShellConsumer()
|
||||||
|
consumer.scope = {"user": user}
|
||||||
|
consumer.server_id = 77
|
||||||
|
consumer.reader_task = reader_task
|
||||||
|
consumer.proc = proc
|
||||||
|
consumer.ssh_started = True
|
||||||
|
consumer.tempdir_path = "/tmp/keywarden-shell-test"
|
||||||
|
consumer._audit_websocket_event = AsyncMock()
|
||||||
|
|
||||||
|
with patch("apps.servers.consumers.shutil.rmtree") as rmtree:
|
||||||
|
await consumer.disconnect(1000)
|
||||||
|
|
||||||
|
consumer._audit_websocket_event.assert_awaited_once()
|
||||||
|
reader_task.cancel.assert_called_once()
|
||||||
|
proc.terminate.assert_called_once()
|
||||||
|
rmtree.assert_called_once_with("/tmp/keywarden-shell-test", ignore_errors=True)
|
||||||
|
|
||||||
|
async def test_stream_output_relays_bytes_and_closes(self):
|
||||||
|
stdout = SimpleNamespace(read=AsyncMock(side_effect=[b"hello", b""]))
|
||||||
|
proc = SimpleNamespace(stdout=stdout, wait=AsyncMock(return_value=0))
|
||||||
|
consumer = ShellConsumer()
|
||||||
|
consumer.proc = proc
|
||||||
|
consumer.send = AsyncMock()
|
||||||
|
consumer.close = AsyncMock()
|
||||||
|
|
||||||
|
await consumer._stream_output()
|
||||||
|
|
||||||
|
consumer.send.assert_any_await(bytes_data=b"hello")
|
||||||
|
consumer.close.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityAndCertificateHelperTests(TestCase):
|
||||||
|
def test_normalize_agent_token_and_hash(self):
|
||||||
|
self.assertEqual(api_security._normalize_agent_token(" Bearer abc123 "), "abc123")
|
||||||
|
self.assertEqual(api_security._normalize_agent_token("token-value"), "token-value")
|
||||||
|
self.assertEqual(api_security.hash_agent_token(""), "")
|
||||||
|
self.assertEqual(
|
||||||
|
api_security.hash_agent_token("Bearer test-token"),
|
||||||
|
api_security.hash_agent_token("test-token"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_ca_and_client_cert(self):
|
||||||
|
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
ca_subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Keywarden Test Agent CA")])
|
||||||
|
now = datetime.now(dt_timezone.utc)
|
||||||
|
ca_cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(ca_subject)
|
||||||
|
.issuer_name(ca_subject)
|
||||||
|
.public_key(ca_key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - timedelta(minutes=5))
|
||||||
|
.not_valid_after(now + timedelta(days=365))
|
||||||
|
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
ca_cert_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||||
|
ca_key_pem = ca_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
).decode("utf-8")
|
||||||
|
|
||||||
|
agent_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
client_subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "agent.example.test")])
|
||||||
|
cert = (
|
||||||
|
x509.CertificateBuilder()
|
||||||
|
.subject_name(client_subject)
|
||||||
|
.issuer_name(ca_cert.subject)
|
||||||
|
.public_key(agent_key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number())
|
||||||
|
.not_valid_before(now - timedelta(minutes=5))
|
||||||
|
.not_valid_after(now + timedelta(days=30))
|
||||||
|
.sign(ca_key, hashes.SHA256())
|
||||||
|
)
|
||||||
|
cert_pem = cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
||||||
|
return ca_cert_pem, ca_key_pem, cert_pem, cert
|
||||||
|
|
||||||
|
def test_agent_mtls_auth_accepts_enrolled_server_certificate(self):
|
||||||
|
ca_cert_pem, ca_key_pem, cert_pem, cert = self._build_ca_and_client_cert()
|
||||||
|
AgentCertificateAuthority.objects.create(
|
||||||
|
name="Test Agent CA",
|
||||||
|
cert_pem=ca_cert_pem,
|
||||||
|
key_pem=ca_key_pem,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
fingerprint = cert.fingerprint(hashes.SHA256()).hex()
|
||||||
|
serial = format(cert.serial_number, "x")
|
||||||
|
server = Server.objects.create(
|
||||||
|
display_name="mtls-1",
|
||||||
|
hostname="mtls-1.example.test",
|
||||||
|
agent_cert_fingerprint=fingerprint,
|
||||||
|
agent_cert_serial=serial,
|
||||||
|
)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
META={
|
||||||
|
"HTTP_X_KEYWARDEN_TLS_CLIENT_VERIFY": "SUCCESS",
|
||||||
|
"HTTP_X_KEYWARDEN_TLS_CLIENT_CERT": quote(cert_pem, safe=""),
|
||||||
|
"HTTP_X_KEYWARDEN_TLS_CLIENT_FINGERPRINT": fingerprint,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
auth = api_security.AgentMTLSAuth()
|
||||||
|
|
||||||
|
principal = auth.authenticate(request)
|
||||||
|
|
||||||
|
self.assertIsNotNone(principal)
|
||||||
|
self.assertEqual(principal.mode, "mtls")
|
||||||
|
self.assertEqual(principal.server_id, server.id)
|
||||||
|
|
||||||
|
def test_agent_mtls_auth_accepts_failed_verify_when_cert_is_valid(self):
|
||||||
|
ca_cert_pem, ca_key_pem, cert_pem, cert = self._build_ca_and_client_cert()
|
||||||
|
AgentCertificateAuthority.objects.create(
|
||||||
|
name="Test Agent CA",
|
||||||
|
cert_pem=ca_cert_pem,
|
||||||
|
key_pem=ca_key_pem,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
fingerprint = cert.fingerprint(hashes.SHA256()).hex()
|
||||||
|
Server.objects.create(
|
||||||
|
display_name="mtls-1b",
|
||||||
|
hostname="mtls-1b.example.test",
|
||||||
|
agent_cert_fingerprint=fingerprint,
|
||||||
|
agent_cert_serial=format(cert.serial_number, "x"),
|
||||||
|
)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
META={
|
||||||
|
"HTTP_X_KEYWARDEN_TLS_CLIENT_VERIFY": "FAILED:unable to verify the first certificate",
|
||||||
|
"HTTP_X_KEYWARDEN_TLS_CLIENT_CERT": quote(cert_pem, safe=""),
|
||||||
|
# nginx $ssl_client_fingerprint is SHA-1 by default.
|
||||||
|
"HTTP_X_KEYWARDEN_TLS_CLIENT_FINGERPRINT": cert.fingerprint(hashes.SHA1()).hex(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
auth = api_security.AgentMTLSAuth()
|
||||||
|
|
||||||
|
principal = auth.authenticate(request)
|
||||||
|
|
||||||
|
self.assertIsNotNone(principal)
|
||||||
|
self.assertEqual(principal.mode, "mtls")
|
||||||
|
|
||||||
|
def test_agent_mtls_auth_rejects_missing_or_mismatched_metadata(self):
|
||||||
|
ca_cert_pem, ca_key_pem, cert_pem, cert = self._build_ca_and_client_cert()
|
||||||
|
AgentCertificateAuthority.objects.create(
|
||||||
|
name="Test Agent CA",
|
||||||
|
cert_pem=ca_cert_pem,
|
||||||
|
key_pem=ca_key_pem,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
Server.objects.create(
|
||||||
|
display_name="mtls-2",
|
||||||
|
hostname="mtls-2.example.test",
|
||||||
|
agent_cert_fingerprint=cert.fingerprint(hashes.SHA256()).hex(),
|
||||||
|
agent_cert_serial=format(cert.serial_number, "x"),
|
||||||
|
)
|
||||||
|
auth = api_security.AgentMTLSAuth()
|
||||||
|
|
||||||
|
missing_verify = SimpleNamespace(
|
||||||
|
META={"HTTP_X_KEYWARDEN_TLS_CLIENT_CERT": quote(cert_pem, safe="")}
|
||||||
|
)
|
||||||
|
self.assertIsNone(auth.authenticate(missing_verify))
|
||||||
|
|
||||||
|
none_verify = SimpleNamespace(
|
||||||
|
META={
|
||||||
|
"HTTP_X_KEYWARDEN_TLS_CLIENT_VERIFY": "NONE",
|
||||||
|
"HTTP_X_KEYWARDEN_TLS_CLIENT_CERT": quote(cert_pem, safe=""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertIsNone(auth.authenticate(none_verify))
|
||||||
|
|
||||||
|
bad_fingerprint = SimpleNamespace(
|
||||||
|
META={
|
||||||
|
"HTTP_X_KEYWARDEN_TLS_CLIENT_VERIFY": "SUCCESS",
|
||||||
|
"HTTP_X_KEYWARDEN_TLS_CLIENT_CERT": quote(cert_pem, safe=""),
|
||||||
|
"HTTP_X_KEYWARDEN_TLS_CLIENT_FINGERPRINT": "00" * 32,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertIsNone(auth.authenticate(bad_fingerprint))
|
||||||
|
|
||||||
|
@override_settings(KEYWARDEN_AGENT_RUNTIME_ALLOW_TOKEN_FALLBACK=True)
|
||||||
|
def test_agent_runtime_auth_falls_back_to_server_token_without_mtls_headers(self):
|
||||||
|
server_token = "server-token-123"
|
||||||
|
server = Server.objects.create(
|
||||||
|
display_name="token-fallback-1",
|
||||||
|
hostname="token-fallback-1.example.test",
|
||||||
|
agent_api_token_hash=api_security.hash_agent_token(server_token),
|
||||||
|
)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
META={"HTTP_AUTHORIZATION": f"Bearer {server_token}"},
|
||||||
|
path="/api/v1/agent/servers/1/log-config",
|
||||||
|
)
|
||||||
|
auth = api_security.AgentRuntimeAuth()
|
||||||
|
|
||||||
|
principal = auth.authenticate(request)
|
||||||
|
|
||||||
|
self.assertIsNotNone(principal)
|
||||||
|
self.assertEqual(principal.mode, "server-token")
|
||||||
|
self.assertEqual(principal.server_id, server.id)
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
KEYWARDEN_AGENT_API_TOKEN="global-token-123",
|
||||||
|
KEYWARDEN_AGENT_RUNTIME_ALLOW_TOKEN_FALLBACK=True,
|
||||||
|
)
|
||||||
|
def test_agent_runtime_auth_accepts_global_token_without_mtls_headers(self):
|
||||||
|
request = SimpleNamespace(
|
||||||
|
META={"HTTP_AUTHORIZATION": "Bearer global-token-123"},
|
||||||
|
path="/api/v1/agent/servers/1/log-config",
|
||||||
|
)
|
||||||
|
auth = api_security.AgentRuntimeAuth()
|
||||||
|
|
||||||
|
principal = auth.authenticate(request)
|
||||||
|
|
||||||
|
self.assertIsNotNone(principal)
|
||||||
|
self.assertEqual(principal.mode, "global-token")
|
||||||
|
self.assertIsNone(principal.server_id)
|
||||||
|
|
||||||
|
@override_settings(KEYWARDEN_AGENT_RUNTIME_ALLOW_TOKEN_FALLBACK=False)
|
||||||
|
def test_agent_runtime_auth_can_disable_token_fallback(self):
|
||||||
|
server_token = "server-token-strict-123"
|
||||||
|
Server.objects.create(
|
||||||
|
display_name="token-fallback-off-1",
|
||||||
|
hostname="token-fallback-off-1.example.test",
|
||||||
|
agent_api_token_hash=api_security.hash_agent_token(server_token),
|
||||||
|
)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
META={"HTTP_AUTHORIZATION": f"Bearer {server_token}"},
|
||||||
|
path="/api/v1/agent/servers/1/log-config",
|
||||||
|
)
|
||||||
|
auth = api_security.AgentRuntimeAuth()
|
||||||
|
|
||||||
|
principal = auth.authenticate(request)
|
||||||
|
|
||||||
|
self.assertIsNone(principal)
|
||||||
|
|
||||||
|
def test_sanitize_username_and_render_system_username(self):
|
||||||
|
self.assertEqual(sanitize_username("Alice Smith"), "alice_smith")
|
||||||
|
self.assertEqual(sanitize_username(" "), "")
|
||||||
|
with override_settings(KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE="{{username}}--{{user_id}}"):
|
||||||
|
rendered = render_system_username("Alice Smith", 42)
|
||||||
|
self.assertEqual(rendered, "alice_smith--42")
|
||||||
|
|
||||||
|
def test_certificate_label_and_comment_helpers(self):
|
||||||
|
self.assertEqual(key_certificates._sanitize_label(" Prod Key #1 "), "prod-key-1")
|
||||||
|
self.assertEqual(key_certificates._sanitize_label("$$$"), "key")
|
||||||
|
self.assertEqual(
|
||||||
|
key_certificates._ensure_comment("ssh-ed25519 AAAA old", "new-comment"),
|
||||||
|
"ssh-ed25519 AAAA new-comment",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
key_certificates._ensure_comment("ssh-ed25519 AAAA", ""),
|
||||||
|
"ssh-ed25519 AAAA",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_write_file_sets_mode(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
target = f"{tmpdir}/test.txt"
|
||||||
|
key_certificates._write_file(target, "hello", 0o600)
|
||||||
|
with open(target, "r", encoding="utf-8") as handle:
|
||||||
|
self.assertEqual(handle.read(), "hello")
|
||||||
|
|
||||||
|
def test_sign_public_key_raises_when_ca_material_missing(self):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
key_certificates._sign_public_key(
|
||||||
|
ca_private_key="",
|
||||||
|
ca_public_key="",
|
||||||
|
public_key="ssh-ed25519 AAAA",
|
||||||
|
identity="id",
|
||||||
|
principal="alice",
|
||||||
|
serial=1,
|
||||||
|
validity_days=1,
|
||||||
|
comment="c",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sign_public_key_raises_when_ssh_keygen_missing(self):
|
||||||
|
with patch("apps.keys.certificates.subprocess.run", side_effect=FileNotFoundError("missing")):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
key_certificates._sign_public_key(
|
||||||
|
ca_private_key="private",
|
||||||
|
ca_public_key="ssh-ed25519 AAAA",
|
||||||
|
public_key="ssh-ed25519 BBBB",
|
||||||
|
identity="id",
|
||||||
|
principal="alice",
|
||||||
|
serial=1,
|
||||||
|
validity_days=1,
|
||||||
|
comment="c",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sign_public_key_happy_path_reads_generated_cert(self):
|
||||||
|
def fake_run(cmd, check, capture_output):
|
||||||
|
pubkey_path = cmd[-1]
|
||||||
|
cert_path = pubkey_path[:-4] + "-cert.pub"
|
||||||
|
with open(cert_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("ssh-ed25519-cert-v01 AAAACERT")
|
||||||
|
return SimpleNamespace(stderr=b"")
|
||||||
|
|
||||||
|
with patch("apps.keys.certificates.subprocess.run", side_effect=fake_run):
|
||||||
|
cert = key_certificates._sign_public_key(
|
||||||
|
ca_private_key="private",
|
||||||
|
ca_public_key="ssh-ed25519 AAAA",
|
||||||
|
public_key="ssh-ed25519 BBBB",
|
||||||
|
identity="id",
|
||||||
|
principal="alice",
|
||||||
|
serial=1,
|
||||||
|
validity_days=1,
|
||||||
|
comment="comment",
|
||||||
|
)
|
||||||
|
self.assertIn("AAAACERT", cert)
|
||||||
|
|
||||||
|
def test_issue_certificate_for_key_updates_record(self):
|
||||||
|
user = SimpleNamespace(id=5, username="alice")
|
||||||
|
key = SimpleNamespace(id=77, user_id=5, user=user, name="Laptop key", public_key="ssh-ed25519 BBBB")
|
||||||
|
fake_ca = SimpleNamespace(
|
||||||
|
private_key="private",
|
||||||
|
public_key="public",
|
||||||
|
get_private_key=lambda: "private",
|
||||||
|
)
|
||||||
|
fake_cert = SimpleNamespace(id=9)
|
||||||
|
with (
|
||||||
|
patch("apps.keys.certificates.get_active_ca", return_value=fake_ca),
|
||||||
|
patch("apps.keys.certificates._sign_public_key", return_value="ssh-ed25519-cert-v01 AAAACERT"),
|
||||||
|
patch("apps.keys.certificates.secrets.randbits", return_value=42),
|
||||||
|
patch("apps.keys.certificates.SSHCertificate.objects.update_or_create", return_value=(fake_cert, True)) as upsert,
|
||||||
|
):
|
||||||
|
cert = key_certificates.issue_certificate_for_key(key)
|
||||||
|
self.assertEqual(cert, fake_cert)
|
||||||
|
upsert.assert_called_once()
|
||||||
|
|
||||||
|
def test_revoke_certificate_for_key_marks_cert_revoked(self):
|
||||||
|
cert = SimpleNamespace(revoke=Mock(), save=Mock())
|
||||||
|
key = SimpleNamespace(certificate=cert)
|
||||||
|
key_certificates.revoke_certificate_for_key(key)
|
||||||
|
cert.revoke.assert_called_once()
|
||||||
|
cert.save.assert_called_once_with(update_fields=["is_active", "revoked_at"])
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
from guardian.shortcuts import assign_perm
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
from apps.keys.models import SSHCertificate, SSHKey
|
||||||
|
from keywarden.api.routers import keys as keys_router
|
||||||
|
|
||||||
|
|
||||||
|
class KeysRouterHelperTests(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="keys-helper",
|
||||||
|
email="keys-helper@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_key(self, *, owner=None, name: str = "helper-key", key_b64: str = "AAAA") -> SSHKey:
|
||||||
|
key = SSHKey(user=owner or self.user, name=name)
|
||||||
|
key.set_public_key(f"ssh-ed25519 {key_b64} comment")
|
||||||
|
key.save()
|
||||||
|
return key
|
||||||
|
|
||||||
|
def test_ensure_certificate_raises_when_key_is_revoked(self) -> None:
|
||||||
|
key = self._create_key()
|
||||||
|
key.is_active = False
|
||||||
|
key.save(update_fields=["is_active"])
|
||||||
|
|
||||||
|
with self.assertRaises(HttpError):
|
||||||
|
keys_router._ensure_certificate(key, self.user)
|
||||||
|
|
||||||
|
def test_ensure_certificate_issues_when_missing_or_expired(self) -> None:
|
||||||
|
key_missing = self._create_key(name="missing")
|
||||||
|
key_expired = self._create_key(name="expired", key_b64="AAAB")
|
||||||
|
SSHCertificate.objects.create(
|
||||||
|
key=key_expired,
|
||||||
|
user=self.user,
|
||||||
|
certificate="ssh-ed25519-cert-v01 AAAA",
|
||||||
|
serial=1001,
|
||||||
|
principals=["keys-helper"],
|
||||||
|
valid_after=timezone.now() - timedelta(days=2),
|
||||||
|
valid_before=timezone.now() - timedelta(days=1),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
issued = SimpleNamespace(serial=999, certificate="ssh-ed25519-cert-v01 BBBB")
|
||||||
|
with patch("keywarden.api.routers.keys.issue_certificate_for_key", return_value=issued) as mocked_issue:
|
||||||
|
self.assertIs(keys_router._ensure_certificate(key_missing, self.user), issued)
|
||||||
|
self.assertIs(keys_router._ensure_certificate(key_expired, self.user), issued)
|
||||||
|
self.assertEqual(mocked_issue.call_count, 2)
|
||||||
|
|
||||||
|
def test_ensure_certificate_returns_existing_active_cert(self) -> None:
|
||||||
|
key = self._create_key()
|
||||||
|
cert = SSHCertificate.objects.create(
|
||||||
|
key=key,
|
||||||
|
user=self.user,
|
||||||
|
certificate="ssh-ed25519-cert-v01 AAAA",
|
||||||
|
serial=42,
|
||||||
|
principals=["keys-helper"],
|
||||||
|
valid_after=timezone.now() - timedelta(minutes=5),
|
||||||
|
valid_before=timezone.now() + timedelta(days=1),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("keywarden.api.routers.keys.issue_certificate_for_key") as mocked_issue:
|
||||||
|
result = keys_router._ensure_certificate(key, self.user)
|
||||||
|
self.assertEqual(result.id, cert.id)
|
||||||
|
mocked_issue.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class KeysApiRouterTests(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
User = get_user_model()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="keys-user",
|
||||||
|
email="keys-user@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
self.other_user = User.objects.create_user(
|
||||||
|
username="keys-other",
|
||||||
|
email="keys-other@example.com",
|
||||||
|
password="pass12345",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _grant_global_perm(self, user, codename: str) -> None:
|
||||||
|
perm = Permission.objects.get(content_type__app_label="keys", codename=codename)
|
||||||
|
user.user_permissions.add(perm)
|
||||||
|
|
||||||
|
def _create_key(self, *, owner=None, name: str = "api-key", key_b64: str = "AAAA") -> SSHKey:
|
||||||
|
key = SSHKey(user=owner or self.user, name=name)
|
||||||
|
key.set_public_key(f"ssh-ed25519 {key_b64} comment")
|
||||||
|
key.save()
|
||||||
|
return key
|
||||||
|
|
||||||
|
def _dummy_cert(self):
|
||||||
|
now = timezone.now()
|
||||||
|
return SimpleNamespace(
|
||||||
|
serial=1234,
|
||||||
|
valid_after=now,
|
||||||
|
valid_before=now + timedelta(days=1),
|
||||||
|
principals=[self.user.username],
|
||||||
|
certificate="ssh-ed25519-cert-v01 AAAATEST",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_list_keys_returns_only_object_permitted_keys(self) -> None:
|
||||||
|
visible = self._create_key(owner=self.other_user, name="visible")
|
||||||
|
hidden = self._create_key(owner=self.other_user, name="hidden", key_b64="AAAB")
|
||||||
|
assign_perm("view_sshkey", self.user, visible)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.get("/api/v1/keys/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
ids = {item["id"] for item in response.json()}
|
||||||
|
self.assertIn(visible.id, ids)
|
||||||
|
self.assertNotIn(hidden.id, ids)
|
||||||
|
|
||||||
|
def test_list_keys_honors_global_filter_by_user(self) -> None:
|
||||||
|
self._grant_global_perm(self.user, "view_sshkey")
|
||||||
|
other_key = self._create_key(owner=self.other_user, name="other", key_b64="AAAC")
|
||||||
|
self._create_key(owner=self.user, name="self", key_b64="AAAD")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/v1/keys/?user_id={self.other_user.id}&limit=20&offset=0")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(len(payload), 1)
|
||||||
|
self.assertEqual(payload[0]["id"], other_key.id)
|
||||||
|
|
||||||
|
def test_create_key_requires_add_permission(self) -> None:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/keys/",
|
||||||
|
data=json.dumps({"name": "laptop", "public_key": "ssh-ed25519 AAAA"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_create_key_for_self_success(self) -> None:
|
||||||
|
self._grant_global_perm(self.user, "add_sshkey")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
with patch("keywarden.api.routers.keys.issue_certificate_for_key", return_value=self._dummy_cert()) as mocked:
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/keys/",
|
||||||
|
data=json.dumps({"name": "laptop", "public_key": "ssh-ed25519 AAAA"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(payload["user_id"], self.user.id)
|
||||||
|
self.assertEqual(payload["name"], "laptop")
|
||||||
|
self.assertTrue(SSHKey.objects.filter(id=payload["id"]).exists())
|
||||||
|
mocked.assert_called_once()
|
||||||
|
|
||||||
|
def test_create_key_for_other_user_requires_admin_view_permission(self) -> None:
|
||||||
|
self._grant_global_perm(self.user, "add_sshkey")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/keys/",
|
||||||
|
data=json.dumps(
|
||||||
|
{"name": "other-user-key", "public_key": "ssh-ed25519 AAAA", "user_id": self.other_user.id}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_create_key_for_other_user_with_admin_perms(self) -> None:
|
||||||
|
self._grant_global_perm(self.user, "add_sshkey")
|
||||||
|
self._grant_global_perm(self.user, "view_sshkey")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
with patch("keywarden.api.routers.keys.issue_certificate_for_key", return_value=self._dummy_cert()):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/keys/",
|
||||||
|
data=json.dumps(
|
||||||
|
{"name": "other-user-key", "public_key": "ssh-ed25519 AAAA", "user_id": self.other_user.id}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(payload["user_id"], self.other_user.id)
|
||||||
|
|
||||||
|
def test_create_key_unknown_user_returns_404(self) -> None:
|
||||||
|
self._grant_global_perm(self.user, "add_sshkey")
|
||||||
|
self._grant_global_perm(self.user, "view_sshkey")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/v1/keys/",
|
||||||
|
data=json.dumps(
|
||||||
|
{"name": "other-user-key", "public_key": "ssh-ed25519 AAAA", "user_id": 999999}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_create_key_validation_paths_raise_type_error(self) -> None:
|
||||||
|
self._grant_global_perm(self.user, "add_sshkey")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
self.client.post(
|
||||||
|
"/api/v1/keys/",
|
||||||
|
data=json.dumps({"name": " ", "public_key": "ssh-ed25519 AAAA"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
self.client.post(
|
||||||
|
"/api/v1/keys/",
|
||||||
|
data=json.dumps({"name": "badkey", "public_key": "not-a-key"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_key_duplicate_fingerprint_raises_type_error(self) -> None:
|
||||||
|
self._grant_global_perm(self.user, "add_sshkey")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self._create_key(owner=self.user, name="existing")
|
||||||
|
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
self.client.post(
|
||||||
|
"/api/v1/keys/",
|
||||||
|
data=json.dumps({"name": "duplicate", "public_key": "ssh-ed25519 AAAA"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_key_not_found_and_forbidden(self) -> None:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
missing = self.client.get("/api/v1/keys/999999")
|
||||||
|
self.assertEqual(missing.status_code, 404)
|
||||||
|
|
||||||
|
key = self._create_key(owner=self.other_user, name="forbidden")
|
||||||
|
forbidden = self.client.get(f"/api/v1/keys/{key.id}")
|
||||||
|
self.assertEqual(forbidden.status_code, 403)
|
||||||
|
|
||||||
|
def test_get_key_success(self) -> None:
|
||||||
|
key = self._create_key(name="visible-key")
|
||||||
|
assign_perm("view_sshkey", self.user, key)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/v1/keys/{key.id}")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["id"], key.id)
|
||||||
|
|
||||||
|
def test_issue_certificate_requires_object_permission(self) -> None:
|
||||||
|
key = self._create_key(owner=self.other_user, name="cert-forbidden")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
forbidden = self.client.post(f"/api/v1/keys/{key.id}/certificate")
|
||||||
|
self.assertEqual(forbidden.status_code, 403)
|
||||||
|
|
||||||
|
missing = self.client.post("/api/v1/keys/999999/certificate")
|
||||||
|
self.assertEqual(missing.status_code, 404)
|
||||||
|
|
||||||
|
def test_issue_certificate_success(self) -> None:
|
||||||
|
key = self._create_key(name="cert-ok")
|
||||||
|
assign_perm("view_sshkey", self.user, key)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
cert = self._dummy_cert()
|
||||||
|
with patch("keywarden.api.routers.keys.issue_certificate_for_key", return_value=cert) as mocked:
|
||||||
|
response = self.client.post(f"/api/v1/keys/{key.id}/certificate")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["serial"], cert.serial)
|
||||||
|
mocked.assert_called_once_with(key, created_by=self.user)
|
||||||
|
|
||||||
|
def test_download_certificate_and_hash(self) -> None:
|
||||||
|
key = self._create_key(name="download")
|
||||||
|
assign_perm("view_sshkey", self.user, key)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
cert = self._dummy_cert()
|
||||||
|
|
||||||
|
with patch("keywarden.api.routers.keys._ensure_certificate", return_value=cert):
|
||||||
|
cert_response = self.client.get(f"/api/v1/keys/{key.id}/certificate")
|
||||||
|
hash_response = self.client.get(f"/api/v1/keys/{key.id}/certificate.sha256")
|
||||||
|
|
||||||
|
self.assertEqual(cert_response.status_code, 200)
|
||||||
|
self.assertIn("attachment;", cert_response["Content-Disposition"])
|
||||||
|
self.assertIn("AAAATEST", cert_response.content.decode("utf-8"))
|
||||||
|
|
||||||
|
self.assertEqual(hash_response.status_code, 200)
|
||||||
|
hash_payload = hash_response.content.decode("utf-8")
|
||||||
|
self.assertIn("keywarden-", hash_payload)
|
||||||
|
self.assertTrue(hash_payload.endswith(".pub\n"))
|
||||||
|
|
||||||
|
def test_download_certificate_requires_permission(self) -> None:
|
||||||
|
key = self._create_key(owner=self.other_user, name="download-forbidden")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
cert_response = self.client.get(f"/api/v1/keys/{key.id}/certificate")
|
||||||
|
hash_response = self.client.get(f"/api/v1/keys/{key.id}/certificate.sha256")
|
||||||
|
|
||||||
|
self.assertEqual(cert_response.status_code, 403)
|
||||||
|
self.assertEqual(hash_response.status_code, 403)
|
||||||
|
|
||||||
|
def test_update_key_paths(self) -> None:
|
||||||
|
key = self._create_key(owner=self.other_user, name="editable", key_b64="AAAE")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
forbidden = self.client.patch(
|
||||||
|
f"/api/v1/keys/{key.id}",
|
||||||
|
data=json.dumps({"name": "renamed"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(forbidden.status_code, 403)
|
||||||
|
|
||||||
|
self._grant_global_perm(self.user, "change_sshkey")
|
||||||
|
assign_perm("change_sshkey", self.user, key)
|
||||||
|
|
||||||
|
renamed = self.client.patch(
|
||||||
|
f"/api/v1/keys/{key.id}",
|
||||||
|
data=json.dumps({"name": "renamed"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(renamed.status_code, 200)
|
||||||
|
key.refresh_from_db()
|
||||||
|
self.assertEqual(key.name, "renamed")
|
||||||
|
|
||||||
|
with patch("keywarden.api.routers.keys.revoke_certificate_for_key") as mocked_revoke:
|
||||||
|
revoked = self.client.patch(
|
||||||
|
f"/api/v1/keys/{key.id}",
|
||||||
|
data=json.dumps({"is_active": False}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(revoked.status_code, 200)
|
||||||
|
key.refresh_from_db()
|
||||||
|
self.assertFalse(key.is_active)
|
||||||
|
self.assertIsNotNone(key.revoked_at)
|
||||||
|
mocked_revoke.assert_called_once_with(key)
|
||||||
|
|
||||||
|
with patch("keywarden.api.routers.keys.issue_certificate_for_key", return_value=self._dummy_cert()) as mocked_issue:
|
||||||
|
reactivated = self.client.patch(
|
||||||
|
f"/api/v1/keys/{key.id}",
|
||||||
|
data=json.dumps({"is_active": True}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(reactivated.status_code, 200)
|
||||||
|
key.refresh_from_db()
|
||||||
|
self.assertTrue(key.is_active)
|
||||||
|
self.assertIsNone(key.revoked_at)
|
||||||
|
mocked_issue.assert_called_once_with(key, created_by=self.user)
|
||||||
|
|
||||||
|
with self.assertRaises(TypeError):
|
||||||
|
self.client.patch(
|
||||||
|
f"/api/v1/keys/{key.id}",
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
not_found = self.client.patch(
|
||||||
|
"/api/v1/keys/999999",
|
||||||
|
data=json.dumps({"name": "x"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(not_found.status_code, 404)
|
||||||
|
|
||||||
|
def test_delete_key_paths(self) -> None:
|
||||||
|
key = self._create_key(owner=self.other_user, name="delete-me", key_b64="AAAF")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
forbidden = self.client.delete(f"/api/v1/keys/{key.id}")
|
||||||
|
self.assertEqual(forbidden.status_code, 403)
|
||||||
|
|
||||||
|
self._grant_global_perm(self.user, "delete_sshkey")
|
||||||
|
assign_perm("delete_sshkey", self.user, key)
|
||||||
|
|
||||||
|
with patch("keywarden.api.routers.keys.revoke_certificate_for_key") as mocked_revoke:
|
||||||
|
deleted = self.client.delete(f"/api/v1/keys/{key.id}")
|
||||||
|
self.assertEqual(deleted.status_code, 204)
|
||||||
|
key.refresh_from_db()
|
||||||
|
self.assertFalse(key.is_active)
|
||||||
|
self.assertIsNotNone(key.revoked_at)
|
||||||
|
mocked_revoke.assert_called_once_with(key)
|
||||||
|
|
||||||
|
second_delete = self.client.delete(f"/api/v1/keys/{key.id}")
|
||||||
|
self.assertEqual(second_delete.status_code, 204)
|
||||||
|
|
||||||
|
missing = self.client.delete("/api/v1/keys/999999")
|
||||||
|
self.assertEqual(missing.status_code, 404)
|
||||||
@@ -6,8 +6,22 @@ app_name = "servers"
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.dashboard, name="dashboard"),
|
path("", views.dashboard, name="dashboard"),
|
||||||
|
path("admin/", views.admin_dashboard, name="admin_dashboard"),
|
||||||
|
path("admin/users/<int:user_id>/password-reset/", views.admin_send_password_reset, name="admin_send_password_reset"),
|
||||||
|
path("admin/users/<int:user_id>/delete/", views.admin_delete_user, name="admin_delete_user"),
|
||||||
|
path("admin/servers/<int:server_id>/delete/", views.admin_delete_server, name="admin_delete_server"),
|
||||||
|
path("admin/grants/<int:grant_id>/revoke/", views.admin_revoke_grant, name="admin_revoke_grant"),
|
||||||
|
path("admin/grants/<int:grant_id>/expiry/", views.admin_change_grant_expiry, name="admin_change_grant_expiry"),
|
||||||
|
path("<int:server_id>/request-access/", views.request_access, name="request_access"),
|
||||||
|
path(
|
||||||
|
"access-requests/<int:request_id>/decision/",
|
||||||
|
views.decide_access_request,
|
||||||
|
name="decide_access_request",
|
||||||
|
),
|
||||||
|
path("<int:server_id>/admin/", views.server_admin, name="server_admin"),
|
||||||
path("<int:server_id>/", views.detail, name="detail"),
|
path("<int:server_id>/", views.detail, name="detail"),
|
||||||
path("<int:server_id>/audit/", views.audit, name="audit"),
|
path("<int:server_id>/audit/", views.audit, name="audit"),
|
||||||
|
path("<int:server_id>/audit/panel/", views.audit_panel, name="audit_panel"),
|
||||||
path("<int:server_id>/shell/", views.shell, name="shell"),
|
path("<int:server_id>/shell/", views.shell, name="shell"),
|
||||||
path("<int:server_id>/settings/", views.settings, name="settings"),
|
path("<int:server_id>/settings/", views.settings, name="settings"),
|
||||||
]
|
]
|
||||||
+1088
-31
File diff suppressed because it is too large.
Load diff
@@ -36,4 +36,12 @@ python manage.py collectstatic --noinput
|
|||||||
python manage.py migrate --noinput
|
python manage.py migrate --noinput
|
||||||
python manage.py ensure_admin
|
python manage.py ensure_admin
|
||||||
|
|
||||||
|
# Ensure application log directory exists and is writable by the Django runtime user.
|
||||||
|
mkdir -p /var/log/keywarden
|
||||||
|
chown -R djangouser:djangouser /var/log/keywarden || true
|
||||||
|
CA_KEY_DIR="${KEYWARDEN_CA_KEY_DIR:-/var/lib/keywarden/ca-keys}"
|
||||||
|
mkdir -p "$CA_KEY_DIR"
|
||||||
|
chmod 700 "$CA_KEY_DIR" || true
|
||||||
|
chown -R djangouser:djangouser "$CA_KEY_DIR" || true
|
||||||
|
|
||||||
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
@@ -22,6 +22,8 @@ class AccessRequestCreateIn(Schema):
|
|||||||
request_shell: bool = False
|
request_shell: bool = False
|
||||||
request_logs: bool = False
|
request_logs: bool = False
|
||||||
request_users: bool = False
|
request_users: bool = False
|
||||||
|
requested_duration_hours: Optional[int] = Field(default=None, ge=1, le=168)
|
||||||
|
requested_server_username: Optional[str] = None
|
||||||
expires_at: Optional[datetime] = None
|
expires_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +41,8 @@ class AccessRequestOut(Schema):
|
|||||||
request_shell: bool
|
request_shell: bool
|
||||||
request_logs: bool
|
request_logs: bool
|
||||||
request_users: bool
|
request_users: bool
|
||||||
|
requested_duration_hours: Optional[int] = None
|
||||||
|
requested_server_username: str
|
||||||
requested_at: str
|
requested_at: str
|
||||||
decided_at: Optional[str] = None
|
decided_at: Optional[str] = None
|
||||||
expires_at: Optional[str] = None
|
expires_at: Optional[str] = None
|
||||||
@@ -63,6 +67,8 @@ def _request_to_out(access_request: AccessRequest) -> AccessRequestOut:
|
|||||||
request_shell=access_request.request_shell,
|
request_shell=access_request.request_shell,
|
||||||
request_logs=access_request.request_logs,
|
request_logs=access_request.request_logs,
|
||||||
request_users=access_request.request_users,
|
request_users=access_request.request_users,
|
||||||
|
requested_duration_hours=access_request.requested_duration_hours,
|
||||||
|
requested_server_username=access_request.requested_server_username or "",
|
||||||
requested_at=access_request.requested_at.isoformat(),
|
requested_at=access_request.requested_at.isoformat(),
|
||||||
decided_at=access_request.decided_at.isoformat() if access_request.decided_at else None,
|
decided_at=access_request.decided_at.isoformat() if access_request.decided_at else None,
|
||||||
expires_at=access_request.expires_at.isoformat() if access_request.expires_at else None,
|
expires_at=access_request.expires_at.isoformat() if access_request.expires_at else None,
|
||||||
@@ -128,6 +134,11 @@ def build_router() -> Router:
|
|||||||
server = Server.objects.get(id=payload.server_id)
|
server = Server.objects.get(id=payload.server_id)
|
||||||
except Server.DoesNotExist:
|
except Server.DoesNotExist:
|
||||||
raise HttpError(404, "Server not found")
|
raise HttpError(404, "Server not found")
|
||||||
|
if not any([payload.request_shell, payload.request_logs, payload.request_users]):
|
||||||
|
raise HttpError(
|
||||||
|
422,
|
||||||
|
{"detail": "Select at least one access scope: shell, logs, or users."},
|
||||||
|
)
|
||||||
access_request = AccessRequest(
|
access_request = AccessRequest(
|
||||||
requester=request.user,
|
requester=request.user,
|
||||||
server=server,
|
server=server,
|
||||||
@@ -135,6 +146,8 @@ def build_router() -> Router:
|
|||||||
request_shell=payload.request_shell,
|
request_shell=payload.request_shell,
|
||||||
request_logs=payload.request_logs,
|
request_logs=payload.request_logs,
|
||||||
request_users=payload.request_users,
|
request_users=payload.request_users,
|
||||||
|
requested_duration_hours=payload.requested_duration_hours,
|
||||||
|
requested_server_username=(payload.requested_server_username or "").strip()[:128],
|
||||||
)
|
)
|
||||||
if payload.expires_at:
|
if payload.expires_at:
|
||||||
access_request.expires_at = payload.expires_at
|
access_request.expires_at = payload.expires_at
|
||||||
@@ -167,9 +180,9 @@ def build_router() -> Router:
|
|||||||
Auth: required.
|
Auth: required.
|
||||||
Permissions: requires `access.change_accessrequest` on the object.
|
Permissions: requires `access.change_accessrequest` on the object.
|
||||||
Rules:
|
Rules:
|
||||||
- Admin/operator (global change) can set status to approved/denied/revoked/cancelled and
|
- Users with global change permission can set status to
|
||||||
update expires_at.
|
approved/denied/revoked/cancelled and update expires_at.
|
||||||
- Non-admin can only set status to cancelled, and only while pending.
|
- Non-global editors can only set status to cancelled, and only while pending.
|
||||||
Side effects: updates object permissions for server visibility when
|
Side effects: updates object permissions for server visibility when
|
||||||
approvals or revocations occur.
|
approvals or revocations occur.
|
||||||
Rationale: this is the core approval/denial path for access control.
|
Rationale: this is the core approval/denial path for access control.
|
||||||
@@ -211,6 +224,15 @@ def build_router() -> Router:
|
|||||||
access_request.decided_by = request.user
|
access_request.decided_by = request.user
|
||||||
else:
|
else:
|
||||||
access_request.decided_by = None
|
access_request.decided_by = None
|
||||||
|
if (
|
||||||
|
is_admin
|
||||||
|
and status == AccessRequest.Status.APPROVED
|
||||||
|
and access_request.requested_duration_hours
|
||||||
|
and not access_request.expires_at
|
||||||
|
):
|
||||||
|
access_request.expires_at = access_request.decided_at + timedelta(
|
||||||
|
hours=access_request.requested_duration_hours
|
||||||
|
)
|
||||||
access_request.save()
|
access_request.save()
|
||||||
sync_server_view_perm(access_request)
|
sync_server_view_perm(access_request)
|
||||||
return _request_to_out(access_request)
|
return _request_to_out(access_request)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from datetime import datetime, timedelta
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
@@ -9,7 +10,9 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import validate_ipv4_address, validate_ipv6_address
|
from django.core.validators import validate_ipv4_address, validate_ipv6_address
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
|
from django.db.models import Q
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from ninja import Body, Router, Schema
|
from ninja import Body, Router, Schema
|
||||||
@@ -17,6 +20,7 @@ from ninja.errors import HttpError
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
|
|
||||||
|
from apps.access.models import AccessRequest
|
||||||
from apps.core.rbac import require_perms
|
from apps.core.rbac import require_perms
|
||||||
from apps.keys.certificates import get_active_ca
|
from apps.keys.certificates import get_active_ca
|
||||||
from apps.keys.models import SSHKey
|
from apps.keys.models import SSHKey
|
||||||
@@ -25,10 +29,13 @@ from apps.servers.models import (
|
|||||||
AgentCertificateAuthority,
|
AgentCertificateAuthority,
|
||||||
EnrollmentToken,
|
EnrollmentToken,
|
||||||
Server,
|
Server,
|
||||||
|
ServerAuditLog,
|
||||||
ServerAccount,
|
ServerAccount,
|
||||||
|
ServerLogSource,
|
||||||
hostname_validator,
|
hostname_validator,
|
||||||
)
|
)
|
||||||
from apps.telemetry.models import TelemetryEvent
|
from apps.telemetry.models import TelemetryEvent
|
||||||
|
from keywarden.api.security import AgentPrincipal, AgentRuntimeAuth, hash_agent_token
|
||||||
|
|
||||||
|
|
||||||
class AuthorizedKeyOut(Schema):
|
class AuthorizedKeyOut(Schema):
|
||||||
@@ -73,6 +80,7 @@ class SyncReportOut(Schema):
|
|||||||
class AgentEnrollIn(Schema):
|
class AgentEnrollIn(Schema):
|
||||||
token: str
|
token: str
|
||||||
csr_pem: str
|
csr_pem: str
|
||||||
|
server_id: Optional[str] = None
|
||||||
host: Optional[str] = None
|
host: Optional[str] = None
|
||||||
ipv4: Optional[str] = None
|
ipv4: Optional[str] = None
|
||||||
ipv6: Optional[str] = None
|
ipv6: Optional[str] = None
|
||||||
@@ -82,12 +90,15 @@ class AgentEnrollOut(Schema):
|
|||||||
server_id: str
|
server_id: str
|
||||||
client_cert_pem: str
|
client_cert_pem: str
|
||||||
ca_cert_pem: str
|
ca_cert_pem: str
|
||||||
|
agent_api_token: str
|
||||||
|
|
||||||
|
|
||||||
class LogEventIn(Schema):
|
class LogEventIn(Schema):
|
||||||
timestamp: str
|
timestamp: str
|
||||||
category: str
|
category: str
|
||||||
event_type: str
|
event_type: str
|
||||||
|
source_kind: Optional[str] = None
|
||||||
|
source_name: Optional[str] = None
|
||||||
unit: Optional[str] = None
|
unit: Optional[str] = None
|
||||||
priority: Optional[str] = None
|
priority: Optional[str] = None
|
||||||
hostname: Optional[str] = None
|
hostname: Optional[str] = None
|
||||||
@@ -105,6 +116,19 @@ class LogIngestOut(Schema):
|
|||||||
accepted: int
|
accepted: int
|
||||||
|
|
||||||
|
|
||||||
|
class LogSourceConfigOut(Schema):
|
||||||
|
source_id: str
|
||||||
|
kind: str
|
||||||
|
name: str
|
||||||
|
service_unit: Optional[str] = None
|
||||||
|
file_path: Optional[str] = None
|
||||||
|
parser: str = "none"
|
||||||
|
include_matches: dict = Field(default_factory=dict)
|
||||||
|
exclude_matches: dict = Field(default_factory=dict)
|
||||||
|
category: str
|
||||||
|
event_type: str
|
||||||
|
|
||||||
|
|
||||||
class AgentHeartbeatIn(Schema):
|
class AgentHeartbeatIn(Schema):
|
||||||
host: Optional[str] = None
|
host: Optional[str] = None
|
||||||
ipv4: Optional[str] = None
|
ipv4: Optional[str] = None
|
||||||
@@ -114,6 +138,7 @@ class AgentHeartbeatIn(Schema):
|
|||||||
|
|
||||||
def build_router() -> Router:
|
def build_router() -> Router:
|
||||||
router = Router()
|
router = Router()
|
||||||
|
agent_auth = AgentRuntimeAuth()
|
||||||
|
|
||||||
@router.post("/enroll", response=AgentEnrollOut, auth=None)
|
@router.post("/enroll", response=AgentEnrollOut, auth=None)
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@@ -124,7 +149,8 @@ def build_router() -> Router:
|
|||||||
enrollment completes.
|
enrollment completes.
|
||||||
Inputs: enrollment token + CSR from the agent, optional host/IP hints.
|
Inputs: enrollment token + CSR from the agent, optional host/IP hints.
|
||||||
Behavior:
|
Behavior:
|
||||||
- Creates a Server record (agent is the source of truth for host/IP).
|
- Creates a Server record (agent is the source of truth for host/IP), or
|
||||||
|
rotates credentials in-place when `server_id` is provided.
|
||||||
- Marks the token as used (single-use).
|
- Marks the token as used (single-use).
|
||||||
- Signs the CSR with the active Agent CA and returns client cert + CA.
|
- Signs the CSR with the active Agent CA and returns client cert + CA.
|
||||||
Rationale: this is the only supported server onboarding flow. If this
|
Rationale: this is the only supported server onboarding flow. If this
|
||||||
@@ -151,39 +177,62 @@ def build_router() -> Router:
|
|||||||
hostname = None
|
hostname = None
|
||||||
ipv4 = _normalize_ip(payload.ipv4, 4)
|
ipv4 = _normalize_ip(payload.ipv4, 4)
|
||||||
ipv6 = _normalize_ip(payload.ipv6, 6)
|
ipv6 = _normalize_ip(payload.ipv6, 6)
|
||||||
|
requested_server_id = _parse_requested_server_id(payload.server_id)
|
||||||
|
|
||||||
csr = _load_csr((payload.csr_pem or "").strip())
|
csr = _load_csr((payload.csr_pem or "").strip())
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
server = Server.objects.create(
|
if requested_server_id is None:
|
||||||
display_name=display_name,
|
server = Server.objects.create(
|
||||||
hostname=hostname,
|
display_name=display_name,
|
||||||
ipv4=ipv4,
|
hostname=hostname,
|
||||||
ipv6=ipv6,
|
ipv4=ipv4,
|
||||||
)
|
ipv6=ipv6,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
server = Server.objects.select_for_update().get(id=requested_server_id)
|
||||||
|
except Server.DoesNotExist:
|
||||||
|
raise HttpError(404, "Server not found for re-enrollment")
|
||||||
|
|
||||||
|
# Optional host/IP updates are best-effort metadata refresh.
|
||||||
|
if host and (server.display_name or "").strip().lower() in {"", "server"}:
|
||||||
|
server.display_name = display_name
|
||||||
|
if hostname:
|
||||||
|
server.hostname = hostname
|
||||||
|
if ipv4:
|
||||||
|
server.ipv4 = ipv4
|
||||||
|
if ipv6:
|
||||||
|
server.ipv6 = ipv6
|
||||||
|
|
||||||
token.mark_used(server)
|
token.mark_used(server)
|
||||||
token.save(update_fields=["used_at", "server"])
|
token.save(update_fields=["used_at", "server"])
|
||||||
cert_pem, ca_pem, fingerprint, serial = _issue_client_cert(csr, host, server.id)
|
cert_pem, ca_pem, fingerprint, serial = _issue_client_cert(csr, host, server.id)
|
||||||
|
agent_api_token = _issue_agent_api_token()
|
||||||
server.agent_enrolled_at = timezone.now()
|
server.agent_enrolled_at = timezone.now()
|
||||||
server.agent_cert_fingerprint = fingerprint
|
server.agent_cert_fingerprint = fingerprint
|
||||||
server.agent_cert_serial = serial
|
server.agent_cert_serial = serial
|
||||||
server.save(update_fields=["agent_enrolled_at", "agent_cert_fingerprint", "agent_cert_serial"])
|
server.agent_api_token_hash = hash_agent_token(agent_api_token)
|
||||||
|
server.save()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
raise HttpError(409, "Server already enrolled")
|
if requested_server_id is None:
|
||||||
|
raise HttpError(409, "Server already enrolled")
|
||||||
|
raise HttpError(409, "Server address already in use")
|
||||||
|
|
||||||
return AgentEnrollOut(
|
return AgentEnrollOut(
|
||||||
server_id=str(server.id),
|
server_id=str(server.id),
|
||||||
client_cert_pem=cert_pem,
|
client_cert_pem=cert_pem,
|
||||||
ca_cert_pem=ca_pem,
|
ca_cert_pem=ca_pem,
|
||||||
|
agent_api_token=agent_api_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut])
|
@router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut])
|
||||||
def authorized_keys(request: HttpRequest, server_id: int):
|
def authorized_keys(request: HttpRequest, server_id: int):
|
||||||
"""Resolve the effective authorized_keys list for a server.
|
"""Resolve the effective authorized_keys list for a server.
|
||||||
|
|
||||||
Auth: required (admin/operator via API).
|
Auth: required (admin-level API permission).
|
||||||
Permissions: requires view access to servers and keys.
|
Permissions: requires view access to servers and keys.
|
||||||
Behavior: uses server object permissions + active SSH keys to produce
|
Behavior: uses access request scopes + active SSH keys to produce
|
||||||
the exact key list the agent should deploy to the server.
|
the exact key list the agent should deploy to the server.
|
||||||
Rationale: this is the policy enforcement point for per-user access.
|
Rationale: this is the policy enforcement point for per-user access.
|
||||||
"""
|
"""
|
||||||
@@ -193,7 +242,7 @@ def build_router() -> Router:
|
|||||||
"keys.view_sshkey",
|
"keys.view_sshkey",
|
||||||
)
|
)
|
||||||
server = _get_server_or_404(server_id)
|
server = _get_server_or_404(server_id)
|
||||||
users = _resolve_access_users(server)
|
users = _resolve_access_users(server, include_shell=True, include_users=False)
|
||||||
key_map = _key_map_for_users(users)
|
key_map = _key_map_for_users(users)
|
||||||
output: list[AuthorizedKeyOut] = []
|
output: list[AuthorizedKeyOut] = []
|
||||||
for user in users:
|
for user in users:
|
||||||
@@ -209,16 +258,17 @@ def build_router() -> Router:
|
|||||||
)
|
)
|
||||||
return output
|
return output
|
||||||
|
|
||||||
@router.get("/servers/{server_id}/accounts", response=List[AccountAccessOut], auth=None)
|
@router.get("/servers/{server_id}/accounts", response=List[AccountAccessOut], auth=agent_auth)
|
||||||
def account_access(request: HttpRequest, server_id: int):
|
def account_access(request: HttpRequest, server_id: int):
|
||||||
"""List accounts that should exist on a server.
|
"""List accounts that should exist on a server.
|
||||||
|
|
||||||
Auth: mTLS expected at the edge (no session/JWT).
|
Auth: per-agent mTLS certificate presented to nginx and validated server-side.
|
||||||
Behavior: resolves active users with server object perms and their keys.
|
Behavior: resolves active users with approved shell/users scopes.
|
||||||
Rationale: drives agent-side account provisioning.
|
Rationale: drives agent-side account provisioning.
|
||||||
"""
|
"""
|
||||||
|
_require_agent_access(request, server_id)
|
||||||
server = _get_server_or_404(server_id)
|
server = _get_server_or_404(server_id)
|
||||||
users = _resolve_access_users(server)
|
users = _resolve_access_users(server, include_shell=True, include_users=True)
|
||||||
return [
|
return [
|
||||||
AccountAccessOut(
|
AccountAccessOut(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -230,29 +280,31 @@ def build_router() -> Router:
|
|||||||
for user in users
|
for user in users
|
||||||
]
|
]
|
||||||
|
|
||||||
@router.get("/servers/{server_id}/ssh-ca", auth=None)
|
@router.get("/servers/{server_id}/ssh-ca", auth=agent_auth)
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
def ssh_ca(request: HttpRequest, server_id: int):
|
def ssh_ca(request: HttpRequest, server_id: int):
|
||||||
"""Return the active SSH user CA public key for agents.
|
"""Return the active SSH user CA public key for agents.
|
||||||
|
|
||||||
Auth: mTLS expected at the edge (no session/JWT).
|
Auth: per-agent mTLS certificate presented to nginx and validated server-side.
|
||||||
"""
|
"""
|
||||||
|
_require_agent_access(request, server_id)
|
||||||
_ = _get_server_or_404(server_id)
|
_ = _get_server_or_404(server_id)
|
||||||
ca = get_active_ca()
|
ca = get_active_ca()
|
||||||
if not ca.public_key:
|
if not ca.public_key:
|
||||||
raise HttpError(404, "SSH CA not configured")
|
raise HttpError(404, "SSH CA not configured")
|
||||||
return {"public_key": ca.public_key, "fingerprint": ca.fingerprint}
|
return {"public_key": ca.public_key, "fingerprint": ca.fingerprint}
|
||||||
|
|
||||||
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut, auth=None)
|
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut, auth=agent_auth)
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn = Body(...)):
|
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn = Body(...)):
|
||||||
"""Record an agent sync report for a server.
|
"""Record an agent sync report for a server.
|
||||||
|
|
||||||
Auth: mTLS expected at the edge (no session/JWT).
|
Auth: per-agent mTLS certificate presented to nginx and validated server-side.
|
||||||
Behavior: stores a telemetry event with counts of applied/revoked keys.
|
Behavior: stores a telemetry event with counts of applied/revoked keys.
|
||||||
Rationale: provides an audit trail of enforcement actions without
|
Rationale: provides an audit trail of enforcement actions without
|
||||||
requiring full log ingestion for every sync cycle.
|
requiring full log ingestion for every sync cycle.
|
||||||
"""
|
"""
|
||||||
|
_require_agent_access(request, server_id)
|
||||||
try:
|
try:
|
||||||
server = Server.objects.get(id=server_id)
|
server = Server.objects.get(id=server_id)
|
||||||
except Server.DoesNotExist:
|
except Server.DoesNotExist:
|
||||||
@@ -273,34 +325,94 @@ def build_router() -> Router:
|
|||||||
_update_server_accounts(server, payload.accounts)
|
_update_server_accounts(server, payload.accounts)
|
||||||
return SyncReportOut(status="ok")
|
return SyncReportOut(status="ok")
|
||||||
|
|
||||||
@router.post("/servers/{server_id}/logs", response=LogIngestOut, auth=None)
|
@router.post("/servers/{server_id}/logs", response=LogIngestOut, auth=agent_auth)
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
def ingest_logs(request: HttpRequest, server_id: int, payload: List[LogEventIn] = Body(...)):
|
def ingest_logs(request: HttpRequest, server_id: int, payload: List[LogEventIn] = Body(...)):
|
||||||
"""Accept log batches from agents for audit collection.
|
"""Accept log batches from agents for audit collection.
|
||||||
|
|
||||||
Auth: mTLS expected at the edge (no session/JWT).
|
Auth: per-agent mTLS certificate presented to nginx and validated server-side.
|
||||||
Behavior: accepts structured log events for later storage and indexing.
|
Behavior: accepts structured log events for later storage and indexing.
|
||||||
Storage: raw logs are persisted separately per-server (SQLite shards),
|
Storage: events are persisted in the primary datastore and indexed per
|
||||||
not in the primary Postgres database.
|
server for filtering in the server audit view.
|
||||||
Rationale: this is the ingestion pipe for security audit logging.
|
Rationale: this is the ingestion pipe for security audit logging.
|
||||||
"""
|
"""
|
||||||
|
_require_agent_access(request, server_id)
|
||||||
try:
|
try:
|
||||||
Server.objects.get(id=server_id)
|
server = Server.objects.get(id=server_id)
|
||||||
except Server.DoesNotExist:
|
except Server.DoesNotExist:
|
||||||
raise HttpError(404, "Server not found")
|
raise HttpError(404, "Server not found")
|
||||||
# TODO: enqueue to Valkey and persist to SQLite slices.
|
if not payload:
|
||||||
return LogIngestOut(status="accepted", accepted=len(payload))
|
return LogIngestOut(status="accepted", accepted=0)
|
||||||
|
|
||||||
@router.post("/servers/{server_id}/heartbeat", response=SyncReportOut, auth=None)
|
now = timezone.now()
|
||||||
|
records: list[ServerAuditLog] = []
|
||||||
|
for event in payload:
|
||||||
|
records.append(
|
||||||
|
ServerAuditLog(
|
||||||
|
server=server,
|
||||||
|
event_at=_parse_event_at(event.timestamp, now),
|
||||||
|
category=_truncate(event.category, 64, default="system"),
|
||||||
|
event_type=_truncate(event.event_type, 128, default="system"),
|
||||||
|
source_kind=_normalize_source_kind(event.source_kind, event.unit, event.fields),
|
||||||
|
source_name=_normalize_source_name(event.source_name, event.unit, event.fields),
|
||||||
|
unit=_truncate(event.unit, 128),
|
||||||
|
priority=_truncate(event.priority, 16),
|
||||||
|
hostname=_truncate(event.hostname, 253),
|
||||||
|
username=_truncate(event.username, 150),
|
||||||
|
principal=_truncate(event.principal, 255),
|
||||||
|
source_ip=_normalize_ip_any(event.source_ip),
|
||||||
|
session_id=_truncate(event.session_id, 128),
|
||||||
|
message=(event.message or "").strip(),
|
||||||
|
raw=event.raw or "",
|
||||||
|
fields=_coerce_fields(event.fields),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ServerAuditLog.objects.bulk_create(records, batch_size=500)
|
||||||
|
return LogIngestOut(status="accepted", accepted=len(records))
|
||||||
|
|
||||||
|
@router.get("/servers/{server_id}/log-config", response=List[LogSourceConfigOut], auth=agent_auth)
|
||||||
|
def log_config(request: HttpRequest, server_id: int):
|
||||||
|
"""Return server log-source configuration consumed by the agent.
|
||||||
|
|
||||||
|
Auth: per-agent mTLS certificate presented to nginx and validated server-side.
|
||||||
|
Behavior: returns enabled journal/service/file log sources for this server.
|
||||||
|
Default: when no sources are defined, an empty list is returned and
|
||||||
|
the agent falls back to current-boot journald collection.
|
||||||
|
"""
|
||||||
|
_require_agent_access(request, server_id)
|
||||||
|
server = _get_server_or_404(server_id)
|
||||||
|
sources = list(server.log_sources.filter(enabled=True).order_by("kind", "name", "id"))
|
||||||
|
if not sources:
|
||||||
|
return []
|
||||||
|
output: list[LogSourceConfigOut] = []
|
||||||
|
for source in sources:
|
||||||
|
output.append(
|
||||||
|
LogSourceConfigOut(
|
||||||
|
source_id=str(source.id),
|
||||||
|
kind=source.kind,
|
||||||
|
name=(source.name or source.service_unit or source.file_path or "").strip(),
|
||||||
|
service_unit=(source.service_unit or "").strip() or None,
|
||||||
|
file_path=(source.file_path or "").strip() or None,
|
||||||
|
parser=(source.parser or "").strip() or "none",
|
||||||
|
include_matches=_clean_source_matches(source.include_matches),
|
||||||
|
exclude_matches=_clean_source_matches(source.exclude_matches),
|
||||||
|
category=(source.category_override or "").strip() or _default_category_for_source(source),
|
||||||
|
event_type=(source.event_type_override or "").strip() or _default_event_type_for_source(source),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
@router.post("/servers/{server_id}/heartbeat", response=SyncReportOut, auth=agent_auth)
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
def heartbeat(request: HttpRequest, server_id: int, payload: AgentHeartbeatIn = Body(...)):
|
def heartbeat(request: HttpRequest, server_id: int, payload: AgentHeartbeatIn = Body(...)):
|
||||||
"""Update server host metadata (hostname/IPs) reported by the agent.
|
"""Update server host metadata (hostname/IPs) reported by the agent.
|
||||||
|
|
||||||
Auth: mTLS expected at the edge (no session/JWT).
|
Auth: per-agent mTLS certificate presented to nginx and validated server-side.
|
||||||
Behavior: updates hostname/IPv4/IPv6 when they change (e.g., DHCP).
|
Behavior: updates hostname/IPv4/IPv6 when they change (e.g., DHCP).
|
||||||
Conflict: unique constraints are enforced; conflicts return 409.
|
Conflict: unique constraints are enforced; conflicts return 409.
|
||||||
Rationale: keeps the server inventory accurate without manual edits.
|
Rationale: keeps the server inventory accurate without manual edits.
|
||||||
"""
|
"""
|
||||||
|
_require_agent_access(request, server_id)
|
||||||
try:
|
try:
|
||||||
server = Server.objects.get(id=server_id)
|
server = Server.objects.get(id=server_id)
|
||||||
except Server.DoesNotExist:
|
except Server.DoesNotExist:
|
||||||
@@ -343,17 +455,82 @@ def _get_server_or_404(server_id: int) -> Server:
|
|||||||
raise HttpError(404, "Server not found")
|
raise HttpError(404, "Server not found")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_access_users(server: Server) -> list:
|
def _parse_requested_server_id(value: Optional[str]) -> Optional[int]:
|
||||||
users = list(
|
text = str(value or "").strip()
|
||||||
get_users_with_perms(
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
server_id = int(text)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HttpError(422, "server_id must be numeric")
|
||||||
|
if server_id <= 0:
|
||||||
|
raise HttpError(422, "server_id must be positive")
|
||||||
|
return server_id
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_agent_api_token() -> str:
|
||||||
|
# Opaque, high-entropy token; only SHA-256 hash is stored server-side.
|
||||||
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_agent_access(request: HttpRequest, server_id: int) -> None:
|
||||||
|
principal = getattr(request, "auth", None)
|
||||||
|
if not isinstance(principal, AgentPrincipal):
|
||||||
|
raise HttpError(401, "Unauthorized")
|
||||||
|
if principal.server_id is None:
|
||||||
|
# Global token compatibility mode: trust caller after auth success.
|
||||||
|
return
|
||||||
|
if int(principal.server_id) != int(server_id):
|
||||||
|
raise HttpError(403, "Forbidden")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_access_users(server: Server, *, include_shell: bool, include_users: bool) -> list:
|
||||||
|
now = timezone.now()
|
||||||
|
user_ids: set[int] = set()
|
||||||
|
active_access_qs = AccessRequest.objects.filter(
|
||||||
|
server=server,
|
||||||
|
status=AccessRequest.Status.APPROVED,
|
||||||
|
).filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
|
||||||
|
active_access_user_ids = set(active_access_qs.values_list("requester_id", flat=True))
|
||||||
|
|
||||||
|
scope_filter = Q()
|
||||||
|
if include_shell:
|
||||||
|
scope_filter |= Q(request_shell=True)
|
||||||
|
if include_users:
|
||||||
|
scope_filter |= Q(request_users=True)
|
||||||
|
if scope_filter:
|
||||||
|
scoped_access_qs = active_access_qs.filter(scope_filter)
|
||||||
|
user_ids.update(scoped_access_qs.values_list("requester_id", flat=True))
|
||||||
|
|
||||||
|
if include_shell:
|
||||||
|
for user in get_users_with_perms(
|
||||||
|
server,
|
||||||
|
only_with_perms_in=["shell_server"],
|
||||||
|
with_group_users=True,
|
||||||
|
with_superusers=False,
|
||||||
|
):
|
||||||
|
if getattr(user, "is_active", False):
|
||||||
|
user_ids.add(user.id)
|
||||||
|
if include_users:
|
||||||
|
for user in get_users_with_perms(
|
||||||
server,
|
server,
|
||||||
only_with_perms_in=["view_server"],
|
only_with_perms_in=["view_server"],
|
||||||
with_group_users=True,
|
with_group_users=True,
|
||||||
with_superusers=False,
|
with_superusers=False,
|
||||||
)
|
):
|
||||||
)
|
if not getattr(user, "is_active", False):
|
||||||
active = [user for user in users if getattr(user, "is_active", False)]
|
continue
|
||||||
return sorted(active, key=lambda user: (user.username or "", user.id))
|
# If access requests exist for this user+server, scopes govern.
|
||||||
|
if user.id in active_access_user_ids:
|
||||||
|
continue
|
||||||
|
user_ids.add(user.id)
|
||||||
|
|
||||||
|
if not user_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
users = list(User.objects.filter(id__in=user_ids, is_active=True))
|
||||||
|
return sorted(users, key=lambda user: (user.username or "", user.id))
|
||||||
|
|
||||||
|
|
||||||
def _key_map_for_users(users: list) -> dict[int, list[SSHKey]]:
|
def _key_map_for_users(users: list) -> dict[int, list[SSHKey]]:
|
||||||
@@ -402,7 +579,7 @@ def _load_agent_ca() -> tuple[x509.Certificate, object, str]:
|
|||||||
raise HttpError(500, "Agent CA not configured")
|
raise HttpError(500, "Agent CA not configured")
|
||||||
try:
|
try:
|
||||||
ca_cert = x509.load_pem_x509_certificate(ca.cert_pem.encode("utf-8"))
|
ca_cert = x509.load_pem_x509_certificate(ca.cert_pem.encode("utf-8"))
|
||||||
ca_key = serialization.load_pem_private_key(ca.key_pem.encode("utf-8"), password=None)
|
ca_key = serialization.load_pem_private_key(ca.get_key_pem().encode("utf-8"), password=None)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
raise HttpError(500, "Invalid agent CA material")
|
raise HttpError(500, "Invalid agent CA material")
|
||||||
return ca_cert, ca_key, ca.cert_pem
|
return ca_cert, ca_key, ca.cert_pem
|
||||||
@@ -463,4 +640,128 @@ def _normalize_ip(value: Optional[str], version: int) -> Optional[str]:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_ip_any(value: Optional[str]) -> Optional[str]:
|
||||||
|
candidate = (value or "").strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
for version in (4, 6):
|
||||||
|
normalized = _normalize_ip(candidate, version)
|
||||||
|
if normalized:
|
||||||
|
return normalized
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(value: Optional[str], max_length: int, default: str = "") -> str:
|
||||||
|
return (value or default).strip()[:max_length]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_event_at(value: Optional[str], fallback: datetime) -> datetime:
|
||||||
|
timestamp = (value or "").strip()
|
||||||
|
parsed = parse_datetime(timestamp) if timestamp else None
|
||||||
|
if parsed is None:
|
||||||
|
if timezone.is_naive(fallback):
|
||||||
|
fallback = timezone.make_aware(fallback, dt_timezone.utc)
|
||||||
|
return fallback.astimezone(dt_timezone.utc)
|
||||||
|
if timezone.is_naive(parsed):
|
||||||
|
parsed = timezone.make_aware(parsed, dt_timezone.utc)
|
||||||
|
return parsed.astimezone(dt_timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_fields(payload: Optional[dict]) -> dict:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return {}
|
||||||
|
clean: dict[str, str] = {}
|
||||||
|
for key, value in payload.items():
|
||||||
|
key_text = str(key).strip()
|
||||||
|
if not key_text:
|
||||||
|
continue
|
||||||
|
clean[key_text] = str(value)
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_source_kind(kind: Optional[str], unit: Optional[str], fields: Optional[dict]) -> str:
|
||||||
|
value = (kind or "").strip().lower()
|
||||||
|
if value in {"service", "file", "journal"}:
|
||||||
|
return value
|
||||||
|
if (unit or "").strip() or ((fields or {}).get("_SYSTEMD_UNIT")):
|
||||||
|
return "service"
|
||||||
|
transport = (fields or {}).get("transport") or (fields or {}).get("_TRANSPORT")
|
||||||
|
if transport:
|
||||||
|
return "journal"
|
||||||
|
file_path = (fields or {}).get("file_path")
|
||||||
|
if file_path:
|
||||||
|
return "file"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_source_name(name: Optional[str], unit: Optional[str], fields: Optional[dict]) -> str:
|
||||||
|
value = _truncate(name, 512)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
unit_value = _truncate(unit, 128)
|
||||||
|
if unit_value:
|
||||||
|
return unit_value
|
||||||
|
raw_unit = _truncate((fields or {}).get("_SYSTEMD_UNIT"), 128)
|
||||||
|
if raw_unit:
|
||||||
|
return raw_unit
|
||||||
|
return _truncate((fields or {}).get("file_path"), 512)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_category_for_source(source: ServerLogSource) -> str:
|
||||||
|
if source.kind == ServerLogSource.Kind.JOURNAL:
|
||||||
|
include_matches = _clean_source_matches(source.include_matches)
|
||||||
|
transports = include_matches.get("_TRANSPORT", [])
|
||||||
|
if any(str(item).strip().lower() == "kernel" for item in transports):
|
||||||
|
return "system"
|
||||||
|
return "system"
|
||||||
|
if source.kind == ServerLogSource.Kind.SERVICE:
|
||||||
|
unit = (source.service_unit or "").lower()
|
||||||
|
if unit in ("sshd.service", "ssh.service"):
|
||||||
|
return "access"
|
||||||
|
if unit == "sudo.service":
|
||||||
|
return "auth"
|
||||||
|
if unit in ("systemd-networkd.service", "networkmanager.service"):
|
||||||
|
return "network"
|
||||||
|
return "system"
|
||||||
|
|
||||||
|
|
||||||
|
def _default_event_type_for_source(source: ServerLogSource) -> str:
|
||||||
|
if source.kind == ServerLogSource.Kind.JOURNAL:
|
||||||
|
include_matches = _clean_source_matches(source.include_matches)
|
||||||
|
transports = include_matches.get("_TRANSPORT", [])
|
||||||
|
if any(str(item).strip().lower() == "kernel" for item in transports):
|
||||||
|
return "kernel"
|
||||||
|
return "system"
|
||||||
|
if source.kind == ServerLogSource.Kind.FILE:
|
||||||
|
return "file.line"
|
||||||
|
if source.kind == ServerLogSource.Kind.SERVICE:
|
||||||
|
unit = (source.service_unit or "").lower()
|
||||||
|
if unit in ("sshd.service", "ssh.service"):
|
||||||
|
return "ssh"
|
||||||
|
if unit == "sudo.service":
|
||||||
|
return "auth"
|
||||||
|
if unit in ("systemd-networkd.service", "networkmanager.service"):
|
||||||
|
return "network"
|
||||||
|
return "system"
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_source_matches(raw: Optional[dict]) -> dict:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return {}
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
for key, value in raw.items():
|
||||||
|
key_text = str(key).strip()
|
||||||
|
if not key_text:
|
||||||
|
continue
|
||||||
|
values: list[str]
|
||||||
|
if isinstance(value, list):
|
||||||
|
values = [str(item).strip() for item in value if str(item).strip()]
|
||||||
|
else:
|
||||||
|
item_text = str(value).strip()
|
||||||
|
values = [item_text] if item_text else []
|
||||||
|
if values:
|
||||||
|
out[key_text] = values
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
router = build_router()
|
router = build_router()
|
||||||
@@ -15,7 +15,7 @@ from apps.core.rbac import ROLE_USER, get_user_role, require_perms, set_user_rol
|
|||||||
class UserCreateIn(Schema):
|
class UserCreateIn(Schema):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str = Field(min_length=8)
|
password: str = Field(min_length=8)
|
||||||
role: Literal["administrator", "operator", "auditor", "user", "admin"]
|
role: Literal["administrator", "user", "admin"]
|
||||||
|
|
||||||
|
|
||||||
class UserListOut(Schema):
|
class UserListOut(Schema):
|
||||||
@@ -34,7 +34,7 @@ class UserDetailOut(Schema):
|
|||||||
|
|
||||||
class UserUpdateIn(Schema):
|
class UserUpdateIn(Schema):
|
||||||
password: str | None = Field(default=None, min_length=8)
|
password: str | None = Field(default=None, min_length=8)
|
||||||
role: Literal["administrator", "operator", "auditor", "user", "admin"] | None = None
|
role: Literal["administrator", "user", "admin"] | None = None
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ def build_router() -> Router:
|
|||||||
"""Create a platform user and assign a Keywarden role.
|
"""Create a platform user and assign a Keywarden role.
|
||||||
|
|
||||||
Auth: required.
|
Auth: required.
|
||||||
Permissions: requires `auth.add_user` (admin/operator).
|
Permissions: requires `auth.add_user` (administrator).
|
||||||
Behavior: uses email as username, hashes the password, and assigns a
|
Behavior: uses email as username, hashes the password, and assigns a
|
||||||
role which maps to Keywarden group permissions.
|
role which maps to Keywarden group permissions.
|
||||||
Rationale: enables automation and external admin workflows; mirrors
|
Rationale: enables automation and external admin workflows; mirrors
|
||||||
|
|||||||
@@ -1,13 +1,52 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone as dt_timezone
|
||||||
|
from secrets import compare_digest
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, ed448, padding, rsa
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractBaseUser
|
from django.contrib.auth.models import AbstractBaseUser
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from ninja.security import HttpBearer
|
from ninja.security import HttpBearer
|
||||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||||
from rest_framework_simplejwt.exceptions import InvalidToken, AuthenticationFailed
|
from rest_framework_simplejwt.exceptions import InvalidToken, AuthenticationFailed
|
||||||
|
|
||||||
|
from apps.servers.models import AgentCertificateAuthority, Server
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
_AGENT_AUTH_LOG_TTL_SECONDS = 60.0
|
||||||
|
_agent_auth_log_cache: dict[str, float] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _log_agent_auth_rejection(request: HttpRequest, reason: str, verify: str, cert_present: bool) -> None:
|
||||||
|
path = str(getattr(request, "path", "") or "")
|
||||||
|
key = f"{reason}|{path}"
|
||||||
|
now = time.monotonic()
|
||||||
|
previous = _agent_auth_log_cache.get(key)
|
||||||
|
if previous is not None and (now - previous) < _AGENT_AUTH_LOG_TTL_SECONDS:
|
||||||
|
return
|
||||||
|
_agent_auth_log_cache[key] = now
|
||||||
|
forwarded_for = str(request.META.get("HTTP_X_FORWARDED_FOR", "")).strip()
|
||||||
|
remote_addr = str(request.META.get("REMOTE_ADDR", "")).strip()
|
||||||
|
logger.warning(
|
||||||
|
"agent auth rejected: reason=%s path=%s verify=%s cert_present=%s remote_addr=%s x_forwarded_for=%s",
|
||||||
|
reason,
|
||||||
|
path or "-",
|
||||||
|
verify or "-",
|
||||||
|
"yes" if cert_present else "no",
|
||||||
|
remote_addr or "-",
|
||||||
|
forwarded_for or "-",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class JWTAuth(HttpBearer):
|
class JWTAuth(HttpBearer):
|
||||||
"""
|
"""
|
||||||
@@ -28,3 +67,227 @@ class JWTAuth(HttpBearer):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentTokenAuth(HttpBearer):
|
||||||
|
"""
|
||||||
|
Auth via Authorization: Bearer <agent token>.
|
||||||
|
Validates enrollment-issued per-server tokens first, with an optional
|
||||||
|
global fallback from KEYWARDEN_AGENT_API_TOKEN for migration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authenticate(self, request: HttpRequest, token: str) -> Optional["AgentPrincipal"]:
|
||||||
|
candidate = _normalize_agent_token(token)
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
|
||||||
|
token_hash = hash_agent_token(candidate)
|
||||||
|
server = Server.objects.filter(agent_api_token_hash=token_hash).only("id").first()
|
||||||
|
if server:
|
||||||
|
return AgentPrincipal(server_id=server.id, mode="server-token")
|
||||||
|
|
||||||
|
configured = _normalize_agent_token(getattr(settings, "KEYWARDEN_AGENT_API_TOKEN", ""))
|
||||||
|
if configured and compare_digest(candidate, configured):
|
||||||
|
return AgentPrincipal(server_id=None, mode="global-token")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRuntimeAuth:
|
||||||
|
"""
|
||||||
|
Runtime agent auth: prefer mTLS, with bearer-token compatibility fallback.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- If a mTLS context appears present, attempt mTLS validation first.
|
||||||
|
- If mTLS is absent/invalid, fall back to enrollment-issued bearer token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._mtls = AgentMTLSAuth()
|
||||||
|
self._token = AgentTokenAuth()
|
||||||
|
|
||||||
|
def __call__(self, request: HttpRequest) -> Optional["AgentPrincipal"]:
|
||||||
|
return self.authenticate(request)
|
||||||
|
|
||||||
|
def authenticate(self, request: HttpRequest) -> Optional["AgentPrincipal"]:
|
||||||
|
bearer = _extract_bearer_token_from_request(request)
|
||||||
|
allow_fallback = bool(
|
||||||
|
getattr(settings, "KEYWARDEN_AGENT_RUNTIME_ALLOW_TOKEN_FALLBACK", True)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not allow_fallback:
|
||||||
|
return self._mtls.authenticate(request)
|
||||||
|
|
||||||
|
# Fast path: no mTLS headers at all but bearer token is present.
|
||||||
|
# This avoids noisy mTLS "missing verify" rejection logs on every poll.
|
||||||
|
if bearer and not _request_has_mtls_context(request):
|
||||||
|
return self._token.authenticate(request, bearer)
|
||||||
|
|
||||||
|
principal = self._mtls.authenticate(request)
|
||||||
|
if principal:
|
||||||
|
return principal
|
||||||
|
if bearer:
|
||||||
|
return self._token.authenticate(request, bearer)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentMTLSAuth:
|
||||||
|
"""
|
||||||
|
Auth via mTLS client certificate details forwarded by nginx.
|
||||||
|
Requires:
|
||||||
|
- X-Keywarden-TLS-Client-Verify: SUCCESS or FAILED:* (client cert presented)
|
||||||
|
- X-Keywarden-TLS-Client-Cert: URL-escaped PEM certificate
|
||||||
|
"""
|
||||||
|
|
||||||
|
verify_header = "HTTP_X_KEYWARDEN_TLS_CLIENT_VERIFY"
|
||||||
|
cert_header = "HTTP_X_KEYWARDEN_TLS_CLIENT_CERT"
|
||||||
|
fingerprint_header = "HTTP_X_KEYWARDEN_TLS_CLIENT_FINGERPRINT"
|
||||||
|
|
||||||
|
def __call__(self, request: HttpRequest) -> Optional["AgentPrincipal"]:
|
||||||
|
return self.authenticate(request)
|
||||||
|
|
||||||
|
def authenticate(self, request: HttpRequest) -> Optional["AgentPrincipal"]:
|
||||||
|
verify = str(request.META.get(self.verify_header, "")).strip().upper()
|
||||||
|
# With nginx `ssl_verify_client optional_no_ca`, a presented client cert
|
||||||
|
# can arrive as SUCCESS or FAILED:<reason> depending on local CA trust.
|
||||||
|
# Treat NONE/missing as unauthenticated, then enforce trust in-app.
|
||||||
|
if not verify or verify == "NONE":
|
||||||
|
_log_agent_auth_rejection(request, "missing_or_none_verify", verify, cert_present=False)
|
||||||
|
return None
|
||||||
|
if not (verify == "SUCCESS" or verify.startswith("FAILED")):
|
||||||
|
_log_agent_auth_rejection(request, "unexpected_verify_state", verify, cert_present=False)
|
||||||
|
return None
|
||||||
|
|
||||||
|
cert_payload = str(request.META.get(self.cert_header, "")).strip()
|
||||||
|
if not cert_payload:
|
||||||
|
_log_agent_auth_rejection(request, "missing_client_cert_header", verify, cert_present=False)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
cert_pem = unquote(cert_payload)
|
||||||
|
cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
_log_agent_auth_rejection(request, "invalid_client_cert_payload", verify, cert_present=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
now = datetime.now(dt_timezone.utc)
|
||||||
|
if hasattr(cert, "not_valid_before_utc"):
|
||||||
|
not_valid_before = cert.not_valid_before_utc
|
||||||
|
not_valid_after = cert.not_valid_after_utc
|
||||||
|
else:
|
||||||
|
not_valid_before = cert.not_valid_before.replace(tzinfo=dt_timezone.utc)
|
||||||
|
not_valid_after = cert.not_valid_after.replace(tzinfo=dt_timezone.utc)
|
||||||
|
if not_valid_before > now or not_valid_after <= now:
|
||||||
|
_log_agent_auth_rejection(request, "client_cert_outside_validity_window", verify, cert_present=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
cert_fingerprint = cert.fingerprint(hashes.SHA256()).hex()
|
||||||
|
cert_fingerprint_sha1 = cert.fingerprint(hashes.SHA1()).hex()
|
||||||
|
header_fingerprint = _normalize_fingerprint(request.META.get(self.fingerprint_header, ""))
|
||||||
|
if header_fingerprint and not (
|
||||||
|
compare_digest(header_fingerprint, cert_fingerprint)
|
||||||
|
or compare_digest(header_fingerprint, cert_fingerprint_sha1)
|
||||||
|
):
|
||||||
|
_log_agent_auth_rejection(request, "fingerprint_header_mismatch", verify, cert_present=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not _signed_by_active_agent_ca(cert):
|
||||||
|
_log_agent_auth_rejection(request, "client_cert_not_signed_by_active_agent_ca", verify, cert_present=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
cert_serial = format(cert.serial_number, "x").lower().lstrip("0") or "0"
|
||||||
|
server = (
|
||||||
|
Server.objects.filter(agent_cert_fingerprint=cert_fingerprint)
|
||||||
|
.only("id", "agent_cert_serial")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not server:
|
||||||
|
_log_agent_auth_rejection(request, "no_server_for_cert_fingerprint", verify, cert_present=True)
|
||||||
|
return None
|
||||||
|
expected_serial = (server.agent_cert_serial or "").strip().lower().lstrip("0") or "0"
|
||||||
|
if expected_serial and expected_serial != cert_serial:
|
||||||
|
_log_agent_auth_rejection(request, "server_serial_mismatch", verify, cert_present=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return AgentPrincipal(server_id=server.id, mode="mtls")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentPrincipal:
|
||||||
|
server_id: Optional[int]
|
||||||
|
mode: str
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_agent_token(value: object) -> str:
|
||||||
|
token = str(value or "").strip()
|
||||||
|
if token.lower().startswith("bearer "):
|
||||||
|
token = token[7:].strip()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bearer_token_from_request(request: HttpRequest) -> str:
|
||||||
|
authz = str(request.META.get("HTTP_AUTHORIZATION", "")).strip()
|
||||||
|
if not authz:
|
||||||
|
return ""
|
||||||
|
parts = authz.split(None, 1)
|
||||||
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||||
|
return ""
|
||||||
|
return _normalize_agent_token(parts[1])
|
||||||
|
|
||||||
|
|
||||||
|
def _request_has_mtls_context(request: HttpRequest) -> bool:
|
||||||
|
verify = str(request.META.get(AgentMTLSAuth.verify_header, "")).strip().upper()
|
||||||
|
cert_payload = str(request.META.get(AgentMTLSAuth.cert_header, "")).strip()
|
||||||
|
if cert_payload:
|
||||||
|
return True
|
||||||
|
if verify and verify != "NONE":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def hash_agent_token(value: str) -> str:
|
||||||
|
normalized = _normalize_agent_token(value)
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_fingerprint(value: object) -> str:
|
||||||
|
return str(value or "").strip().lower().replace(":", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _signed_by_active_agent_ca(cert: x509.Certificate) -> bool:
|
||||||
|
active_cas = AgentCertificateAuthority.objects.filter(
|
||||||
|
is_active=True, revoked_at__isnull=True
|
||||||
|
).only("cert_pem")
|
||||||
|
for ca in active_cas:
|
||||||
|
try:
|
||||||
|
ca_cert = x509.load_pem_x509_certificate(ca.cert_pem.encode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if cert.issuer != ca_cert.subject:
|
||||||
|
continue
|
||||||
|
if _verify_signed_by(cert, ca_cert):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_signed_by(cert: x509.Certificate, ca_cert: x509.Certificate) -> bool:
|
||||||
|
public_key = ca_cert.public_key()
|
||||||
|
try:
|
||||||
|
if isinstance(public_key, rsa.RSAPublicKey):
|
||||||
|
public_key.verify(
|
||||||
|
cert.signature,
|
||||||
|
cert.tbs_certificate_bytes,
|
||||||
|
padding.PKCS1v15(),
|
||||||
|
cert.signature_hash_algorithm,
|
||||||
|
)
|
||||||
|
elif isinstance(public_key, ec.EllipticCurvePublicKey):
|
||||||
|
public_key.verify(
|
||||||
|
cert.signature,
|
||||||
|
cert.tbs_certificate_bytes,
|
||||||
|
ec.ECDSA(cert.signature_hash_algorithm),
|
||||||
|
)
|
||||||
|
elif isinstance(public_key, (ed25519.Ed25519PublicKey, ed448.Ed448PublicKey)):
|
||||||
|
public_key.verify(cert.signature, cert.tbs_certificate_bytes)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
except (InvalidSignature, ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
+102
-85
@@ -22,6 +22,12 @@ def env_csv(name: str, default: str = "") -> list[str]:
|
|||||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
# Auth mode: native | oidc | hybrid
|
||||||
|
KEYWARDEN_AUTH_MODE_REQUESTED = os.getenv("KEYWARDEN_AUTH_MODE", "hybrid").lower()
|
||||||
|
if KEYWARDEN_AUTH_MODE_REQUESTED not in {"native", "oidc", "hybrid"}:
|
||||||
|
KEYWARDEN_AUTH_MODE_REQUESTED = "hybrid"
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
SECRET_KEY = os.getenv("KEYWARDEN_SECRET_KEY")
|
SECRET_KEY = os.getenv("KEYWARDEN_SECRET_KEY")
|
||||||
@@ -33,55 +39,51 @@ CSRF_TRUSTED_ORIGINS = [
|
|||||||
for origin in os.getenv("KEYWARDEN_TRUSTED_ORIGINS", "").split(",")
|
for origin in os.getenv("KEYWARDEN_TRUSTED_ORIGINS", "").split(",")
|
||||||
if origin.strip()
|
if origin.strip()
|
||||||
]
|
]
|
||||||
|
KEYWARDEN_IDP_ACCOUNT_PORTAL_URL = os.getenv("KEYWARDEN_IDP_ACCOUNT_PORTAL_URL", "").strip()
|
||||||
|
|
||||||
|
EMAIL_BACKEND = os.getenv(
|
||||||
|
"KEYWARDEN_EMAIL_BACKEND",
|
||||||
|
"django.core.mail.backends.smtp.EmailBackend",
|
||||||
|
)
|
||||||
|
EMAIL_HOST = os.getenv("KEYWARDEN_EMAIL_HOST", "localhost")
|
||||||
|
EMAIL_PORT = int(os.getenv("KEYWARDEN_EMAIL_PORT", "25"))
|
||||||
|
EMAIL_HOST_USER = os.getenv("KEYWARDEN_EMAIL_HOST_USER", "")
|
||||||
|
EMAIL_HOST_PASSWORD = os.getenv("KEYWARDEN_EMAIL_HOST_PASSWORD", "")
|
||||||
|
EMAIL_USE_TLS = env_bool("KEYWARDEN_EMAIL_USE_TLS", default=False)
|
||||||
|
EMAIL_USE_SSL = env_bool("KEYWARDEN_EMAIL_USE_SSL", default=False)
|
||||||
|
DEFAULT_FROM_EMAIL = os.getenv("KEYWARDEN_DEFAULT_FROM_EMAIL", "no-reply@localhost")
|
||||||
|
SERVER_EMAIL = os.getenv("KEYWARDEN_SERVER_EMAIL", DEFAULT_FROM_EMAIL)
|
||||||
|
KEYWARDEN_EMAIL_VERIFICATION_ENABLED = env_bool("KEYWARDEN_EMAIL_VERIFICATION_ENABLED", default=True)
|
||||||
|
KEYWARDEN_EMAIL_VERIFICATION_TOKEN_MAX_AGE_SECONDS = int(
|
||||||
|
os.getenv("KEYWARDEN_EMAIL_VERIFICATION_TOKEN_MAX_AGE_SECONDS", "86400")
|
||||||
|
)
|
||||||
|
KEYWARDEN_EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS = int(
|
||||||
|
os.getenv("KEYWARDEN_EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS", "300")
|
||||||
|
)
|
||||||
|
|
||||||
KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID = os.getenv("KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID", "").strip()
|
|
||||||
KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET = os.getenv("KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET", "").strip()
|
|
||||||
KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID = os.getenv("KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID", "").strip()
|
KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID = os.getenv("KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID", "").strip()
|
||||||
KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET = os.getenv("KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET", "").strip()
|
KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET = os.getenv("KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET", "").strip()
|
||||||
KEYWARDEN_SOCIAL_APPLE_CLIENT_ID = os.getenv("KEYWARDEN_SOCIAL_APPLE_CLIENT_ID", "").strip()
|
|
||||||
KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET = os.getenv("KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET", "").strip()
|
|
||||||
|
|
||||||
KEYWARDEN_SOCIAL_GOOGLE_ENABLED = env_bool(
|
|
||||||
"KEYWARDEN_SOCIAL_GOOGLE_ENABLED",
|
|
||||||
default=bool(KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID and KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET),
|
|
||||||
)
|
|
||||||
KEYWARDEN_SOCIAL_GITHUB_ENABLED = env_bool(
|
KEYWARDEN_SOCIAL_GITHUB_ENABLED = env_bool(
|
||||||
"KEYWARDEN_SOCIAL_GITHUB_ENABLED",
|
"KEYWARDEN_SOCIAL_GITHUB_ENABLED",
|
||||||
default=bool(KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID and KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET),
|
default=bool(KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID and KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET),
|
||||||
)
|
)
|
||||||
KEYWARDEN_SOCIAL_APPLE_ENABLED = env_bool(
|
|
||||||
"KEYWARDEN_SOCIAL_APPLE_ENABLED",
|
|
||||||
default=bool(KEYWARDEN_SOCIAL_APPLE_CLIENT_ID and KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET),
|
|
||||||
)
|
|
||||||
|
|
||||||
KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED = bool(
|
|
||||||
KEYWARDEN_SOCIAL_GOOGLE_ENABLED
|
|
||||||
and KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID
|
|
||||||
and KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET
|
|
||||||
)
|
|
||||||
KEYWARDEN_SOCIAL_GITHUB_CONFIGURED = bool(
|
KEYWARDEN_SOCIAL_GITHUB_CONFIGURED = bool(
|
||||||
KEYWARDEN_SOCIAL_GITHUB_ENABLED
|
KEYWARDEN_SOCIAL_GITHUB_ENABLED
|
||||||
and KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID
|
and KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID
|
||||||
and KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET
|
and KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET
|
||||||
)
|
)
|
||||||
KEYWARDEN_SOCIAL_APPLE_CONFIGURED = bool(
|
|
||||||
KEYWARDEN_SOCIAL_APPLE_ENABLED
|
|
||||||
and KEYWARDEN_SOCIAL_APPLE_CLIENT_ID
|
|
||||||
and KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET
|
|
||||||
)
|
|
||||||
|
|
||||||
KEYWARDEN_SOCIAL_AUTH_ENABLED = any(
|
KEYWARDEN_SOCIAL_AUTH_ENABLED = (
|
||||||
(
|
KEYWARDEN_AUTH_MODE_REQUESTED == "hybrid"
|
||||||
KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED,
|
and KEYWARDEN_SOCIAL_GITHUB_CONFIGURED
|
||||||
KEYWARDEN_SOCIAL_GITHUB_CONFIGURED,
|
|
||||||
KEYWARDEN_SOCIAL_APPLE_CONFIGURED,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default to secure cookies and respect TLS termination headers.
|
# Default to secure cookies and respect TLS termination headers.
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
CSRF_COOKIE_SECURE = True
|
CSRF_COOKIE_SECURE = True
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
|
CSRF_COOKIE_HTTPONLY = True
|
||||||
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"unfold.contrib.guardian",
|
"unfold.contrib.guardian",
|
||||||
@@ -120,12 +122,8 @@ if KEYWARDEN_SOCIAL_AUTH_ENABLED:
|
|||||||
"allauth.socialaccount",
|
"allauth.socialaccount",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
if KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED:
|
|
||||||
INSTALLED_APPS.append("allauth.socialaccount.providers.google")
|
|
||||||
if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
|
if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
|
||||||
INSTALLED_APPS.append("allauth.socialaccount.providers.github")
|
INSTALLED_APPS.append("allauth.socialaccount.providers.github")
|
||||||
if KEYWARDEN_SOCIAL_APPLE_CONFIGURED:
|
|
||||||
INSTALLED_APPS.append("allauth.socialaccount.providers.apple")
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
@@ -180,6 +178,9 @@ CHANNEL_LAYERS = {
|
|||||||
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
|
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TIME_ZONE = "UTC"
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||||
SESSION_CACHE_ALIAS = "default"
|
SESSION_CACHE_ALIAS = "default"
|
||||||
|
|
||||||
@@ -187,10 +188,33 @@ SESSION_CACHE_ALIAS = "default"
|
|||||||
KEYWARDEN_AGENT_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_AGENT_CERT_VALIDITY_DAYS", "90"))
|
KEYWARDEN_AGENT_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_AGENT_CERT_VALIDITY_DAYS", "90"))
|
||||||
KEYWARDEN_USER_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_USER_CERT_VALIDITY_DAYS", "30"))
|
KEYWARDEN_USER_CERT_VALIDITY_DAYS = int(os.getenv("KEYWARDEN_USER_CERT_VALIDITY_DAYS", "30"))
|
||||||
KEYWARDEN_SHELL_CERT_VALIDITY_MINUTES = int(os.getenv("KEYWARDEN_SHELL_CERT_VALIDITY_MINUTES", "15"))
|
KEYWARDEN_SHELL_CERT_VALIDITY_MINUTES = int(os.getenv("KEYWARDEN_SHELL_CERT_VALIDITY_MINUTES", "15"))
|
||||||
|
KEYWARDEN_SHELL_DEBUG = os.getenv("KEYWARDEN_SHELL_DEBUG", "0").strip().lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
}
|
||||||
|
KEYWARDEN_SHELL_PRESERVE_TMP = os.getenv("KEYWARDEN_SHELL_PRESERVE_TMP", "0").strip().lower() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
}
|
||||||
|
KEYWARDEN_SHELL_DIAG_TIMEOUT_SECONDS = int(os.getenv("KEYWARDEN_SHELL_DIAG_TIMEOUT_SECONDS", "10"))
|
||||||
|
KEYWARDEN_SHELL_REMOTE_COMMAND = os.getenv("KEYWARDEN_SHELL_REMOTE_COMMAND", "/bin/bash")
|
||||||
|
KEYWARDEN_SHELL_STRICT_HOST_KEY_CHECKING = os.getenv(
|
||||||
|
"KEYWARDEN_SHELL_STRICT_HOST_KEY_CHECKING", "accept-new"
|
||||||
|
).strip().lower()
|
||||||
|
KEYWARDEN_CA_KEY_DIR = os.getenv("KEYWARDEN_CA_KEY_DIR", "/var/lib/keywarden/ca-keys").strip()
|
||||||
KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE = os.getenv(
|
KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE = os.getenv(
|
||||||
"KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE", "{{username}}_{{user_id}}"
|
"KEYWARDEN_ACCOUNT_USERNAME_TEMPLATE", "{{username}}_{{user_id}}"
|
||||||
)
|
)
|
||||||
KEYWARDEN_HEARTBEAT_STALE_SECONDS = int(os.getenv("KEYWARDEN_HEARTBEAT_STALE_SECONDS", "120"))
|
KEYWARDEN_HEARTBEAT_STALE_SECONDS = int(os.getenv("KEYWARDEN_HEARTBEAT_STALE_SECONDS", "120"))
|
||||||
|
KEYWARDEN_AGENT_API_TOKEN = os.getenv("KEYWARDEN_AGENT_API_TOKEN", "").strip()
|
||||||
|
KEYWARDEN_AGENT_RUNTIME_ALLOW_TOKEN_FALLBACK = env_bool(
|
||||||
|
"KEYWARDEN_AGENT_RUNTIME_ALLOW_TOKEN_FALLBACK",
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
|
||||||
CELERY_BROKER_URL = os.getenv("KEYWARDEN_CELERY_BROKER_URL", REDIS_URL)
|
CELERY_BROKER_URL = os.getenv("KEYWARDEN_CELERY_BROKER_URL", REDIS_URL)
|
||||||
CELERY_RESULT_BACKEND = os.getenv("KEYWARDEN_CELERY_RESULT_BACKEND", REDIS_URL)
|
CELERY_RESULT_BACKEND = os.getenv("KEYWARDEN_CELERY_RESULT_BACKEND", REDIS_URL)
|
||||||
@@ -216,6 +240,8 @@ PASSWORD_HASHERS = [
|
|||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
STATIC_ROOT = BASE_DIR/"static"
|
STATIC_ROOT = BASE_DIR/"static"
|
||||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
|
# Avoid wildcard CORS on static assets (Access-Control-Allow-Origin: *).
|
||||||
|
WHITENOISE_ALLOW_ALL_ORIGINS = False
|
||||||
|
|
||||||
TAILWIND_APP_NAME="theme"
|
TAILWIND_APP_NAME="theme"
|
||||||
|
|
||||||
@@ -265,7 +291,8 @@ UNFOLD = {
|
|||||||
# Force a consistent admin theme; disables theme switching.
|
# Force a consistent admin theme; disables theme switching.
|
||||||
"THEME": "dark", # Force theme: "dark" or "light". Will disable theme switcher
|
"THEME": "dark", # Force theme: "dark" or "light". Will disable theme switcher
|
||||||
"SIDEBAR": {
|
"SIDEBAR": {
|
||||||
"show_search": True,
|
"show_search": False,
|
||||||
|
"command_search": False,
|
||||||
"show_all_applications": True,
|
"show_all_applications": True,
|
||||||
"navigation": [
|
"navigation": [
|
||||||
{
|
{
|
||||||
@@ -284,7 +311,7 @@ UNFOLD = {
|
|||||||
"STYLES": [
|
"STYLES": [
|
||||||
"/static/unfold/css/styles.css",
|
"/static/unfold/css/styles.css",
|
||||||
"/static/unfold/css/simplebar.css",
|
"/static/unfold/css/simplebar.css",
|
||||||
#(lambda request: "/static/unfold/css/keywarden.css"),
|
"/static/unfold/css/keywarden.css",
|
||||||
],
|
],
|
||||||
# "TABS": [
|
# "TABS": [
|
||||||
# {
|
# {
|
||||||
@@ -331,7 +358,8 @@ OIDC_OP_JWKS_ENDPOINT = os.getenv("KEYWARDEN_OIDC_JWKS_ENDPOINT")
|
|||||||
OIDC_RP_SIGN_ALGO = os.getenv("KEYWARDEN_OIDC_SIGN_ALGO", "RS256")
|
OIDC_RP_SIGN_ALGO = os.getenv("KEYWARDEN_OIDC_SIGN_ALGO", "RS256")
|
||||||
OIDC_RP_SCOPES = os.getenv("KEYWARDEN_OIDC_SCOPES", "openid email profile")
|
OIDC_RP_SCOPES = os.getenv("KEYWARDEN_OIDC_SCOPES", "openid email profile")
|
||||||
OIDC_STORE_ACCESS_TOKEN = env_bool("KEYWARDEN_OIDC_STORE_ACCESS_TOKEN", default=False)
|
OIDC_STORE_ACCESS_TOKEN = env_bool("KEYWARDEN_OIDC_STORE_ACCESS_TOKEN", default=False)
|
||||||
OIDC_CREATE_USER = True
|
KEYWARDEN_OIDC_ALLOW_AUTO_ONBOARDING = env_bool("KEYWARDEN_OIDC_ALLOW_AUTO_ONBOARDING", default=True)
|
||||||
|
OIDC_CREATE_USER = KEYWARDEN_OIDC_ALLOW_AUTO_ONBOARDING
|
||||||
|
|
||||||
KEYWARDEN_OIDC_PROVIDER_ID = os.getenv("KEYWARDEN_OIDC_PROVIDER_ID", "oidc")
|
KEYWARDEN_OIDC_PROVIDER_ID = os.getenv("KEYWARDEN_OIDC_PROVIDER_ID", "oidc")
|
||||||
KEYWARDEN_OIDC_ISSUER = os.getenv("KEYWARDEN_OIDC_ISSUER", "").strip()
|
KEYWARDEN_OIDC_ISSUER = os.getenv("KEYWARDEN_OIDC_ISSUER", "").strip()
|
||||||
@@ -340,7 +368,10 @@ KEYWARDEN_OIDC_EMAIL_VERIFIED_CLAIM = os.getenv("KEYWARDEN_OIDC_EMAIL_VERIFIED_C
|
|||||||
KEYWARDEN_OIDC_REQUIRE_VERIFIED_EMAIL = env_bool("KEYWARDEN_OIDC_REQUIRE_VERIFIED_EMAIL", default=True)
|
KEYWARDEN_OIDC_REQUIRE_VERIFIED_EMAIL = env_bool("KEYWARDEN_OIDC_REQUIRE_VERIFIED_EMAIL", default=True)
|
||||||
KEYWARDEN_OIDC_USERNAME_CLAIM = os.getenv("KEYWARDEN_OIDC_USERNAME_CLAIM", "preferred_username")
|
KEYWARDEN_OIDC_USERNAME_CLAIM = os.getenv("KEYWARDEN_OIDC_USERNAME_CLAIM", "preferred_username")
|
||||||
KEYWARDEN_OIDC_GROUPS_CLAIM = os.getenv("KEYWARDEN_OIDC_GROUPS_CLAIM", "groups")
|
KEYWARDEN_OIDC_GROUPS_CLAIM = os.getenv("KEYWARDEN_OIDC_GROUPS_CLAIM", "groups")
|
||||||
KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS = env_bool("KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS", default=False)
|
KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS = env_bool(
|
||||||
|
"KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS",
|
||||||
|
default=(KEYWARDEN_AUTH_MODE_REQUESTED == "oidc"),
|
||||||
|
)
|
||||||
KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS = env_bool("KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS", default=False)
|
KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS = env_bool("KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS", default=False)
|
||||||
KEYWARDEN_OIDC_ADMIN_GROUPS = env_csv("KEYWARDEN_OIDC_ADMIN_GROUPS")
|
KEYWARDEN_OIDC_ADMIN_GROUPS = env_csv("KEYWARDEN_OIDC_ADMIN_GROUPS")
|
||||||
|
|
||||||
@@ -358,18 +389,17 @@ KEYWARDEN_OIDC_ENABLED = bool(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
KEYWARDEN_OIDC_LOGIN_ENABLED = KEYWARDEN_OIDC_ENABLED and KEYWARDEN_AUTH_MODE_REQUESTED in {"oidc", "hybrid"}
|
||||||
|
|
||||||
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS = []
|
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS = []
|
||||||
if KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED:
|
if KEYWARDEN_SOCIAL_AUTH_ENABLED and KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
|
||||||
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append(
|
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append(
|
||||||
{"id": "google", "name": "Google", "login_url": "/accounts/sso/google/login/"}
|
{
|
||||||
)
|
"id": "github",
|
||||||
if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
|
"name": "GitHub",
|
||||||
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append(
|
"login_url": "/accounts/sso/github/login/",
|
||||||
{"id": "github", "name": "GitHub", "login_url": "/accounts/sso/github/login/"}
|
"connect_url": "/accounts/sso/github/login/?process=connect",
|
||||||
)
|
}
|
||||||
if KEYWARDEN_SOCIAL_APPLE_CONFIGURED:
|
|
||||||
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append(
|
|
||||||
{"id": "apple", "name": "Apple", "login_url": "/accounts/sso/apple/login/"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if KEYWARDEN_SOCIAL_AUTH_ENABLED:
|
if KEYWARDEN_SOCIAL_AUTH_ENABLED:
|
||||||
@@ -378,24 +408,14 @@ if KEYWARDEN_SOCIAL_AUTH_ENABLED:
|
|||||||
ACCOUNT_UNIQUE_EMAIL = True
|
ACCOUNT_UNIQUE_EMAIL = True
|
||||||
ACCOUNT_USERNAME_REQUIRED = False
|
ACCOUNT_USERNAME_REQUIRED = False
|
||||||
ACCOUNT_AUTHENTICATION_METHOD = "email"
|
ACCOUNT_AUTHENTICATION_METHOD = "email"
|
||||||
SOCIALACCOUNT_AUTO_SIGNUP = True
|
# Social identities can only be linked to an existing Keywarden account.
|
||||||
|
KEYWARDEN_SOCIAL_ALLOW_AUTO_ONBOARDING = False
|
||||||
|
SOCIALACCOUNT_AUTO_SIGNUP = False
|
||||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION = False
|
SOCIALACCOUNT_EMAIL_AUTHENTICATION = False
|
||||||
SOCIALACCOUNT_ADAPTER = "apps.accounts.social_auth.KeywardenSocialAccountAdapter"
|
SOCIALACCOUNT_ADAPTER = "apps.accounts.social_auth.KeywardenSocialAccountAdapter"
|
||||||
SOCIALACCOUNT_LOGIN_ON_GET = True
|
SOCIALACCOUNT_LOGIN_ON_GET = True
|
||||||
SOCIALACCOUNT_PROVIDERS = {}
|
SOCIALACCOUNT_PROVIDERS = {}
|
||||||
|
|
||||||
if KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED:
|
|
||||||
SOCIALACCOUNT_PROVIDERS["google"] = {
|
|
||||||
"SCOPE": ["profile", "email"],
|
|
||||||
"APPS": [
|
|
||||||
{
|
|
||||||
"client_id": KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID,
|
|
||||||
"secret": KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET,
|
|
||||||
"key": "",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
|
if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
|
||||||
SOCIALACCOUNT_PROVIDERS["github"] = {
|
SOCIALACCOUNT_PROVIDERS["github"] = {
|
||||||
"SCOPE": ["read:user", "user:email"],
|
"SCOPE": ["read:user", "user:email"],
|
||||||
@@ -408,46 +428,43 @@ if KEYWARDEN_SOCIAL_AUTH_ENABLED:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
if KEYWARDEN_SOCIAL_APPLE_CONFIGURED:
|
|
||||||
SOCIALACCOUNT_PROVIDERS["apple"] = {
|
|
||||||
"SCOPE": ["name", "email"],
|
|
||||||
"APPS": [
|
|
||||||
{
|
|
||||||
"client_id": KEYWARDEN_SOCIAL_APPLE_CLIENT_ID,
|
|
||||||
"secret": KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET,
|
|
||||||
"key": "",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL = env_bool(
|
KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL = env_bool(
|
||||||
"KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL",
|
"KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL",
|
||||||
default=True,
|
default=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auth mode: native | oidc | hybrid
|
# Auth mode: native | oidc | hybrid
|
||||||
AUTH_MODE = os.getenv("KEYWARDEN_AUTH_MODE", "hybrid").lower()
|
AUTH_MODE = KEYWARDEN_AUTH_MODE_REQUESTED
|
||||||
if AUTH_MODE not in {"native", "oidc", "hybrid"}:
|
|
||||||
AUTH_MODE = "hybrid"
|
|
||||||
if AUTH_MODE == "oidc" and not KEYWARDEN_OIDC_ENABLED:
|
if AUTH_MODE == "oidc" and not KEYWARDEN_OIDC_ENABLED:
|
||||||
AUTH_MODE = "native"
|
AUTH_MODE = "native"
|
||||||
KEYWARDEN_AUTH_MODE = AUTH_MODE
|
KEYWARDEN_AUTH_MODE = AUTH_MODE
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
if AUTH_MODE == "oidc":
|
||||||
"django.contrib.auth.backends.ModelBackend",
|
AUTHENTICATION_BACKENDS = [
|
||||||
"guardian.backends.ObjectPermissionBackend",
|
"apps.accounts.oidc.KeywardenOIDCAuthenticationBackend",
|
||||||
]
|
"guardian.backends.ObjectPermissionBackend",
|
||||||
if KEYWARDEN_SOCIAL_AUTH_ENABLED:
|
]
|
||||||
AUTHENTICATION_BACKENDS.append("allauth.account.auth_backends.AuthenticationBackend")
|
elif AUTH_MODE == "hybrid":
|
||||||
if KEYWARDEN_OIDC_ENABLED:
|
AUTHENTICATION_BACKENDS = [
|
||||||
AUTHENTICATION_BACKENDS.append("apps.accounts.oidc.KeywardenOIDCAuthenticationBackend")
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
|
"guardian.backends.ObjectPermissionBackend",
|
||||||
|
]
|
||||||
|
if KEYWARDEN_SOCIAL_AUTH_ENABLED:
|
||||||
|
AUTHENTICATION_BACKENDS.append("allauth.account.auth_backends.AuthenticationBackend")
|
||||||
|
if KEYWARDEN_OIDC_ENABLED:
|
||||||
|
AUTHENTICATION_BACKENDS.append("apps.accounts.oidc.KeywardenOIDCAuthenticationBackend")
|
||||||
|
else:
|
||||||
|
AUTHENTICATION_BACKENDS = [
|
||||||
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
|
"guardian.backends.ObjectPermissionBackend",
|
||||||
|
]
|
||||||
|
|
||||||
if AUTH_MODE == "oidc":
|
if AUTH_MODE == "oidc":
|
||||||
# OIDC-only: enforce identity provider logins.
|
# OIDC-only: enforce identity provider logins.
|
||||||
LOGIN_URL = "/oidc/authenticate/"
|
LOGIN_URL = "/oidc/authenticate/"
|
||||||
else:
|
else:
|
||||||
LOGIN_URL = "/accounts/login/"
|
LOGIN_URL = "/accounts/login/"
|
||||||
LOGOUT_URL = "/oidc/logout/" if KEYWARDEN_OIDC_ENABLED else "/accounts/logout/"
|
LOGOUT_URL = "/oidc/logout/" if AUTH_MODE in {"oidc", "hybrid"} and KEYWARDEN_OIDC_ENABLED else "/accounts/logout/"
|
||||||
LOGIN_REDIRECT_URL = "/servers/"
|
LOGIN_REDIRECT_URL = "/servers/"
|
||||||
LOGOUT_REDIRECT_URL = "/"
|
LOGOUT_REDIRECT_URL = "/"
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ from keywarden.api import api as ninja_api, api_v1 as ninja_api_v1
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("oidc/", include("mozilla_django_oidc.urls")),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if getattr(settings, "KEYWARDEN_OIDC_LOGIN_ENABLED", False):
|
||||||
|
urlpatterns.append(path("oidc/", include("mozilla_django_oidc.urls")))
|
||||||
|
|
||||||
if getattr(settings, "KEYWARDEN_SOCIAL_AUTH_ENABLED", False):
|
if getattr(settings, "KEYWARDEN_SOCIAL_AUTH_ENABLED", False):
|
||||||
urlpatterns.append(path("accounts/sso/", include("allauth.urls")))
|
urlpatterns.append(path("accounts/sso/", include("allauth.urls")))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const outputDirArg = process.argv[2];
|
||||||
|
if (!outputDirArg) {
|
||||||
|
console.error("Usage: node capture_ui_screenshots.mjs <output-dir>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputDir = path.resolve(outputDirArg);
|
||||||
|
const baseUrl = (process.env.KEYWARDEN_SCREENSHOT_BASE_URL || "https://localhost").replace(/\/+$/, "");
|
||||||
|
const username = process.env.KEYWARDEN_ADMIN_USERNAME || "admin";
|
||||||
|
const adminEmail = process.env.KEYWARDEN_ADMIN_EMAIL || "";
|
||||||
|
const password = process.env.KEYWARDEN_ADMIN_PASSWORD || "";
|
||||||
|
const requestedServerId = process.env.KEYWARDEN_SCREENSHOT_SERVER_ID || "";
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
console.error("KEYWARDEN_ADMIN_PASSWORD is required for authenticated screenshots.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
viewport: { width: 1600, height: 900 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
const shots = [];
|
||||||
|
|
||||||
|
async function screenshot(name, label, gotoUrl = null) {
|
||||||
|
const fileName = `${name}.png`;
|
||||||
|
const filePath = path.join(outputDir, fileName);
|
||||||
|
const item = {
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
requestedUrl: gotoUrl || page.url(),
|
||||||
|
finalUrl: "",
|
||||||
|
status: "ok",
|
||||||
|
file: fileName,
|
||||||
|
error: "",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (gotoUrl) {
|
||||||
|
await page.goto(gotoUrl, { waitUntil: "domcontentloaded", timeout: 45000 });
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
await page.screenshot({ path: filePath, fullPage: true });
|
||||||
|
item.finalUrl = page.url();
|
||||||
|
} catch (err) {
|
||||||
|
item.status = "error";
|
||||||
|
item.error = String(err);
|
||||||
|
item.finalUrl = page.url();
|
||||||
|
}
|
||||||
|
shots.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureNativeLoginForm() {
|
||||||
|
if (await page.locator('input[name="username"]').count()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nativeLink = page.getByRole("link", { name: /Log in with Keywarden/i });
|
||||||
|
if (await nativeLink.count()) {
|
||||||
|
await nativeLink.first().click();
|
||||||
|
await page.waitForLoadState("domcontentloaded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.goto(`${baseUrl}/accounts/login/?native=1`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
await screenshot("01-login-entry", "Login entry", `${baseUrl}/accounts/login/`);
|
||||||
|
const candidates = [...new Set([username, adminEmail].filter(Boolean))];
|
||||||
|
if (!candidates.length) {
|
||||||
|
throw new Error("No login identifier candidates available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let loggedIn = false;
|
||||||
|
for (const identifier of candidates) {
|
||||||
|
await page.goto(`${baseUrl}/accounts/login/?native=1`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
||||||
|
await ensureNativeLoginForm();
|
||||||
|
await screenshot("02-login-native", "Native login form");
|
||||||
|
|
||||||
|
if (!(await page.locator('input[name="username"]').count())) {
|
||||||
|
throw new Error("Could not find native login form.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.fill('input[name="username"]', identifier);
|
||||||
|
await page.fill('input[name="password"]', password);
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 45000 }).catch(() => null),
|
||||||
|
page.getByRole("button", { name: /Sign in/i }).click(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.goto(`${baseUrl}/servers/`, { waitUntil: "domcontentloaded", timeout: 45000 });
|
||||||
|
if (!page.url().includes("/accounts/login")) {
|
||||||
|
loggedIn = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loggedIn) {
|
||||||
|
throw new Error("Login failed for all configured identifiers.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await screenshot("03-dashboard", "Dashboard", `${baseUrl}/servers/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectServerId() {
|
||||||
|
if (requestedServerId) {
|
||||||
|
return requestedServerId;
|
||||||
|
}
|
||||||
|
const hrefs = await page.$$eval("a[href]", (nodes) => nodes.map((node) => node.getAttribute("href") || ""));
|
||||||
|
for (const href of hrefs) {
|
||||||
|
const match = href.match(/^\/servers\/(\d+)\/$/);
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login();
|
||||||
|
await screenshot("04-profile", "Profile/key upload", `${baseUrl}/accounts/profile/`);
|
||||||
|
await screenshot("05-server-dashboard", "Server dashboard", `${baseUrl}/servers/`);
|
||||||
|
await screenshot("06-admin-dashboard", "Access grant/revocation queue", `${baseUrl}/servers/admin/`);
|
||||||
|
await screenshot("07-admin-server-reg", "Server registration (Django admin)", `${baseUrl}/admin/servers/enrollmenttoken/`);
|
||||||
|
|
||||||
|
const serverId = await detectServerId();
|
||||||
|
if (serverId) {
|
||||||
|
await screenshot("08-server-detail", "Server detail/heartbeat", `${baseUrl}/servers/${serverId}/`);
|
||||||
|
await screenshot("09-server-audit", "Server audit logs", `${baseUrl}/servers/${serverId}/audit/`);
|
||||||
|
await screenshot("10-server-settings", "Server sync/log settings", `${baseUrl}/servers/${serverId}/settings/`);
|
||||||
|
await screenshot("11-server-shell", "Browser shell interface", `${baseUrl}/servers/${serverId}/shell/`);
|
||||||
|
await screenshot("12-server-admin", "Server access admin view", `${baseUrl}/servers/${serverId}/admin/`);
|
||||||
|
} else {
|
||||||
|
shots.push({
|
||||||
|
name: "server-detection",
|
||||||
|
label: "Server routes skipped",
|
||||||
|
requestedUrl: `${baseUrl}/servers/`,
|
||||||
|
finalUrl: page.url(),
|
||||||
|
status: "error",
|
||||||
|
file: "",
|
||||||
|
error: "No server id detected from dashboard links.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
generatedAtUtc: new Date().toISOString(),
|
||||||
|
baseUrl,
|
||||||
|
username,
|
||||||
|
shots,
|
||||||
|
};
|
||||||
|
await fs.writeFile(path.join(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
||||||
|
|
||||||
|
const mdLines = [
|
||||||
|
"# Screenshot Index",
|
||||||
|
"",
|
||||||
|
`- Base URL: \`${baseUrl}\``,
|
||||||
|
`- Generated: \`${manifest.generatedAtUtc}\``,
|
||||||
|
"",
|
||||||
|
"| File | Label | Status | URL |",
|
||||||
|
"|---|---|---|---|",
|
||||||
|
];
|
||||||
|
for (const shot of shots) {
|
||||||
|
const filePart = shot.file ? `\`${shot.file}\`` : "(none)";
|
||||||
|
const urlPart = shot.finalUrl || shot.requestedUrl || "";
|
||||||
|
const status = shot.status === "ok" ? "ok" : `error: ${shot.error}`;
|
||||||
|
mdLines.push(`| ${filePart} | ${shot.label} | ${status} | \`${urlPart}\` |`);
|
||||||
|
}
|
||||||
|
mdLines.push("");
|
||||||
|
await fs.writeFile(path.join(outputDir, "README.md"), mdLines.join("\n"));
|
||||||
|
|
||||||
|
console.log(`Captured ${shots.filter((shot) => shot.status === "ok").length}/${shots.length} screenshots to ${outputDir}`);
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
exec gunicorn keywarden.wsgi:application --bind 0.0.0.0:8000 --workers 3
|
exec gunicorn keywarden.wsgi:application \
|
||||||
|
--bind 0.0.0.0:8000 \
|
||||||
|
--workers 3 \
|
||||||
|
--access-logfile /var/log/keywarden/gunicorn-access.log \
|
||||||
|
--error-logfile /var/log/keywarden/gunicorn-error.log \
|
||||||
|
--capture-output \
|
||||||
|
--log-level info
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
(function () {
|
||||||
|
const allauth = window.allauth = window.allauth || {}
|
||||||
|
|
||||||
|
function manageEmailForm (o) {
|
||||||
|
const actions = document.getElementsByName('action_remove')
|
||||||
|
if (actions.length) {
|
||||||
|
actions[0].addEventListener('click', function (e) {
|
||||||
|
if (!window.confirm(o.i18n.confirmDelete)) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allauth.account = {
|
||||||
|
forms: {
|
||||||
|
manageEmailForm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
(function () {
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
Array.from(document.querySelectorAll('script[data-allauth-onload]')).forEach(scriptElt => {
|
||||||
|
const funcRef = scriptElt.dataset.allauthOnload
|
||||||
|
if (typeof funcRef === 'string' && funcRef.startsWith('allauth.')) {
|
||||||
|
const funcArg = JSON.parse(scriptElt.textContent)
|
||||||
|
const func = funcRef.split('.').reduce((acc, part) => acc && acc[part], window)
|
||||||
|
func(funcArg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})()
|
||||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "Space Grotesk";
|
||||||
|
src: url("../fonts/space-grotesk/SpaceGrotesk-VariableFont_wght.ttf") format("truetype");
|
||||||
|
font-weight: 300 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
Space Grotesk Variable Font
|
||||||
|
===========================
|
||||||
|
|
||||||
|
This download contains Space Grotesk as both a variable font and static fonts.
|
||||||
|
|
||||||
|
Space Grotesk is a variable font with this axis:
|
||||||
|
wght
|
||||||
|
|
||||||
|
This means all the styles are contained in a single file:
|
||||||
|
SpaceGrotesk-VariableFont_wght.ttf
|
||||||
|
|
||||||
|
If your app fully supports variable fonts, you can now pick intermediate styles
|
||||||
|
that aren’t available as static fonts. Not all apps support variable fonts, and
|
||||||
|
in those cases you can use the static font files for Space Grotesk:
|
||||||
|
static/SpaceGrotesk-Light.ttf
|
||||||
|
static/SpaceGrotesk-Regular.ttf
|
||||||
|
static/SpaceGrotesk-Medium.ttf
|
||||||
|
static/SpaceGrotesk-SemiBold.ttf
|
||||||
|
static/SpaceGrotesk-Bold.ttf
|
||||||
|
|
||||||
|
Get started
|
||||||
|
-----------
|
||||||
|
|
||||||
|
1. Install the font files you want to use
|
||||||
|
|
||||||
|
2. Use your app's font picker to view the font family and all the
|
||||||
|
available styles
|
||||||
|
|
||||||
|
Learn more about variable fonts
|
||||||
|
-------------------------------
|
||||||
|
|
||||||
|
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
|
||||||
|
https://variablefonts.typenetwork.com
|
||||||
|
https://medium.com/variable-fonts
|
||||||
|
|
||||||
|
In desktop apps
|
||||||
|
|
||||||
|
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
|
||||||
|
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
|
||||||
|
|
||||||
|
Online
|
||||||
|
|
||||||
|
https://developers.google.com/fonts/docs/getting_started
|
||||||
|
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
|
||||||
|
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
|
||||||
|
|
||||||
|
Installing fonts
|
||||||
|
|
||||||
|
MacOS: https://support.apple.com/en-us/HT201749
|
||||||
|
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
|
||||||
|
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
|
||||||
|
|
||||||
|
Android Apps
|
||||||
|
|
||||||
|
https://developers.google.com/fonts/docs/android
|
||||||
|
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
|
||||||
|
|
||||||
|
License
|
||||||
|
-------
|
||||||
|
Please read the full license text (OFL.txt) to understand the permissions,
|
||||||
|
restrictions and requirements for usage, redistribution, and modification.
|
||||||
|
|
||||||
|
You can use them in your products & projects – print or digital,
|
||||||
|
commercial or otherwise.
|
||||||
|
|
||||||
|
This isn't legal advice, please consider consulting a lawyer and see the full
|
||||||
|
license for all details.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -97,3 +97,16 @@ html:not(.dark) .kw-pill {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Emergency presentation fix: hide Unfold search UI everywhere in admin. */
|
||||||
|
form[action$="/admin/search/"],
|
||||||
|
form[action*="/admin/search/?"],
|
||||||
|
a[href$="/admin/search/"],
|
||||||
|
a[href*="/admin/search/?"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep Unfold command modal from blocking admin login if Alpine state desyncs. */
|
||||||
|
body.login #modal-overlay,
|
||||||
|
body.login [x-show="openModal"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
/*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */
|
/*! tailwindcss v4.2.0 | MIT License | https://tailwindcss.com */
|
||||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%;--tw-content:""}}}@layer theme{:root,:host{--font-sans:"Inter", sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-500:oklch(79.5% .184 86.047);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--animate-spin:Line truncated
|
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%;--tw-content:""}}}@layer theme{:root,:host{--font-sans:"Inter", sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-500:oklch(79.5% .184 86.047);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-6xl:3.75rem;--text-6xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1Line truncated
|
||||||
@@ -21,7 +21,35 @@ window.addEventListener("load", (e) => {
|
|||||||
*************************************************************/
|
*************************************************************/
|
||||||
function theme(defaultTheme = "auto") {
|
function theme(defaultTheme = "auto") {
|
||||||
return {
|
return {
|
||||||
|
openModal: false,
|
||||||
|
filterOpen: false,
|
||||||
|
openAllApplications: false,
|
||||||
adminTheme: Alpine.$persist(defaultTheme).as('adminTheme'),
|
adminTheme: Alpine.$persist(defaultTheme).as('adminTheme'),
|
||||||
|
init() {
|
||||||
|
this.$watch('openModal', (value) => {
|
||||||
|
if (value) {
|
||||||
|
document.getElementsByTagName("body")[0].classList.add("overflow-hidden");
|
||||||
|
} else {
|
||||||
|
document.getElementsByTagName("body")[0].classList.remove("overflow-hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.$watch('filterOpen', (value) => {
|
||||||
|
if (value) {
|
||||||
|
document.getElementsByTagName("body")[0].classList.add("overflow-hidden");
|
||||||
|
} else {
|
||||||
|
document.getElementsByTagName("body")[0].classList.remove("overflow-hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.$watch('openAllApplications', (value) => {
|
||||||
|
if (value) {
|
||||||
|
document.getElementsByTagName("body")[0].classList.add("overflow-hidden");
|
||||||
|
} else {
|
||||||
|
document.getElementsByTagName("body")[0].classList.remove("overflow-hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
switchTheme(theme) {
|
switchTheme(theme) {
|
||||||
this.adminTheme = theme;
|
this.adminTheme = theme;
|
||||||
},
|
},
|
||||||
@@ -351,7 +379,7 @@ function searchCommand() {
|
|||||||
this.scrollToActiveItem();
|
this.scrollToActiveItem();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
selectItem(addHistory) {
|
selectItem(addHistory, openInNewTab=false) {
|
||||||
const link = this.items[this.currentIndex - 1].querySelector("a");
|
const link = this.items[this.currentIndex - 1].querySelector("a");
|
||||||
const data = {
|
const data = {
|
||||||
title: link.dataset.title,
|
title: link.dataset.title,
|
||||||
@@ -364,7 +392,11 @@ function searchCommand() {
|
|||||||
this.addToHistory(data);
|
this.addToHistory(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location = link.href;
|
if (openInNewTab) {
|
||||||
|
window.open(link.href, "_blank");
|
||||||
|
} else {
|
||||||
|
window.location = link.href;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
addToHistory(data) {
|
addToHistory(data) {
|
||||||
let commandHistory = JSON.parse(
|
let commandHistory = JSON.parse(
|
||||||
@@ -495,6 +527,15 @@ function dateTimeShortcutsOverlay() {
|
|||||||
for (const mutationRecord of mutations) {
|
for (const mutationRecord of mutations) {
|
||||||
const display = mutationRecord.target.style.display;
|
const display = mutationRecord.target.style.display;
|
||||||
const overlay = document.getElementById("modal-overlay");
|
const overlay = document.getElementById("modal-overlay");
|
||||||
|
if (!overlay) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfold uses this overlay for command modal via Alpine state.
|
||||||
|
// Don't force inline display changes when Alpine is in control.
|
||||||
|
if (overlay.getAttribute("x-show") === "openModal") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (display === "block") {
|
if (display === "block") {
|
||||||
overlay.style.display = "block";
|
overlay.style.display = "block";
|
||||||
@@ -517,6 +558,8 @@ function dateTimeShortcutsOverlay() {
|
|||||||
childList: true,
|
childList: true,
|
||||||
subtree: true,
|
subtree: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
findCalendars();
|
||||||
};
|
};
|
||||||
|
|
||||||
/*************************************************************
|
/*************************************************************
|
||||||
|
|||||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user