Compare commits

...
10 Commits
133 changed files with 12774 additions and 671 deletions

No files matched your search

+39 -11
View File
@@ -4,6 +4,23 @@ KEYWARDEN_DEBUG=True
KEYWARDEN_ALLOWED_HOSTS=*
KEYWARDEN_TRUSTED_ORIGINS=https://reverse.proxy.domain.xyz,https://127.0.0.1
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
KEYWARDEN_POSTGRES_DB=keywarden
@@ -19,12 +36,28 @@ KEYWARDEN_ADMIN_EMAIL=admin@example.com
KEYWARDEN_ADMIN_PASSWORD=password
# Auth mode: native | oidc | hybrid
# native: Uses JWT authentication based on Username / Password
# oidc: Disables in-build auth, exclusively uses OIDC configuration
# hybrid: Allows native auth, prefers oidc
# native: Uses Keywarden username/password login only. OIDC/social options are hidden and disabled.
# oidc: Disables built-in login and uses OIDC only. Users can be auto-onboarded from IdP claims.
# hybrid: Supports OIDC + Keywarden native login; login screen prefers OIDC/social options first.
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.
# KEYWARDEN_OIDC_CLIENT_ID=
@@ -41,24 +74,19 @@ KEYWARDEN_AUTH_MODE=native
# KEYWARDEN_OIDC_EMAIL_CLAIM=email
# KEYWARDEN_OIDC_EMAIL_VERIFIED_CLAIM=email_verified
# KEYWARDEN_OIDC_USERNAME_CLAIM=preferred_username
# KEYWARDEN_OIDC_ALLOW_AUTO_ONBOARDING=true
# 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_DEMOTE_ON_MISS=false
# Social login (optional)
# KEYWARDEN_SITE_ID=1
# 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_CLIENT_ID=
# KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET=
# KEYWARDEN_SOCIAL_APPLE_ENABLED=false
# KEYWARDEN_SOCIAL_APPLE_CLIENT_ID=
# KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET=
# Authentik Secrets
AUTHENTIK_PG_PASS=
AUTHENTIK_SECRET_KEY=
AUTHENTIK_SECRET_KEY=
+5 -1
View File
@@ -223,4 +223,8 @@ nginx/certs/*.pem
*.pdf
theme/__pycache__/*
theme/__pycache__/*
evidence/runs/
app/.*
app/scripts/node_modules
+1 -1
View File
@@ -36,7 +36,7 @@ Endpoints:
- `GET /api/v1/keys/{key_id}/certificate` downloads the certificate.
- `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}/accounts` returns account + system username (no raw keys).
+4 -1
View File
@@ -9,7 +9,10 @@ ENV PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=on
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.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
Binary file not shown.
+12 -15
View File
@@ -2,7 +2,7 @@
This guide configures:
- OIDC client login (Authentik, Authelia, Keycloak, or other OIDC IdP)
- Social login (Google, GitHub, Apple)
- Social login (GitHub)
Security model implemented by Keywarden:
- 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
```
## 5. Social providers
## 5. GitHub social provider
Enable providers by credentials (or explicit `*_ENABLED=true`).
Enable GitHub by credentials (or explicit `*_ENABLED=true`).
```env
KEYWARDEN_SITE_ID=1
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_CLIENT_ID=<github-client-id>
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:
@@ -147,10 +139,15 @@ Keywarden includes social auth URLs under:
/accounts/sso/
```
Provider callback URLs to register:
- Google: `https://<your-keywarden-domain>/accounts/sso/google/login/callback/`
Provider callback URL to register:
- 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
@@ -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.
- `/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.
- Changing provider email for the same subject causes login rejection.
+35
View File
@@ -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
View File
@@ -12,16 +12,92 @@ go build -o keywarden-agent ./cmd/keywarden-agent
## 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.
## 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
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.
`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`.
For package alternatives and external collector options, see `LOGGING_PACKAGES.md`.
+297 -13
View File
@@ -4,15 +4,20 @@ import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
"sort"
"strings"
"syscall"
"time"
@@ -23,10 +28,16 @@ import (
"keywarden/agent/internal/version"
)
var lastNoEventsNotice time.Time
func main() {
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)")
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")
flag.Parse()
@@ -43,7 +54,7 @@ func main() {
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)
}
@@ -80,20 +91,32 @@ func runOnce(ctx context.Context, apiClient *client.Client, cfg *config.Config)
log.Printf("host update deferred; will retry: %v", err)
} else {
log.Printf("host update error: %v", err)
logAgentUnauthorizedHint(err)
}
}
if err := apiClient.SyncAccounts(ctx, cfg); err != nil {
log.Printf("sync accounts error: %v", err)
logAgentUnauthorizedHint(err)
}
if err := shipLogs(ctx, apiClient, cfg); err != nil {
if client.IsRetriable(err) {
log.Printf("log shipping deferred; will retry: %v", err)
} else {
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 {
if err := os.MkdirAll(cfg.StateDir, 0o700); err != nil {
return err
@@ -114,16 +137,56 @@ func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config)
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 {
return err
}
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 {
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 maybeLogNoEventsNotice(len(sources)) && len(sources) == 0 {
log.Printf("log shipping file fallback probe: %s", describeFileSourceAvailability(defaultFileFallbackSources()))
}
return nil
}
payload, err := json.Marshal(events)
@@ -136,7 +199,7 @@ func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config)
}
return err
}
if err := logs.WriteCursor(cfg.LogCursorPath(), nextCursor); err != nil {
if err := logs.WriteState(cfg.LogStatePath(), nextState); err != nil {
return err
}
return nil
@@ -172,12 +235,25 @@ func pickEnrollToken(flagValue string) string {
return os.Getenv("KEYWARDEN_ENROLL_TOKEN")
}
func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string) error {
if cfg.ServerID != "" && fileExists(cfg.ClientCertPath()) && fileExists(cfg.CACertPath()) {
func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string, forceEnroll bool) error {
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
}
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()
if !fileExists(keyPath) {
@@ -192,15 +268,23 @@ func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string
info := host.Detect()
hostname := info.Hostname
resp, err := client.Enroll(context.Background(), cfg.ServerURL, client.EnrollRequest{
Token: enrollToken,
CSRPEM: csrPEM,
Host: hostname,
IPv4: info.IPv4,
IPv6: info.IPv6,
Token: enrollToken,
CSRPEM: csrPEM,
Host: hostname,
IPv4: info.IPv4,
IPv6: info.IPv6,
ServerID: rotateServerID,
})
if err != nil {
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 {
return err
}
@@ -208,12 +292,156 @@ func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string
return err
}
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 {
return err
}
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 {
var lastErr error
for attempt := 0; attempt <= len(delays); attempt++ {
@@ -276,3 +504,59 @@ func fileExists(path string) bool {
}
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, ";")
}
+54 -4
View File
@@ -1,9 +1,59 @@
{
"server_url": "https://keywarden.dev.ntbx.io/api/v1",
"server_id": "4",
"server_ca_path": "",
"server_url": "https://keywarden.example.com/api/v1",
"server_id": "6",
"agent_api_token": "HV8MnbysmINShu9wkXzzNrQE5NyRlPJHfP2aU6SX4HERrlac8EQpoMEu02FX1TYv",
"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",
"account_policy": {
"username_template": "{{username}}_{{user_id}}",
+14
View File
@@ -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
}
}
+80 -6
View File
@@ -28,6 +28,18 @@ type Client struct {
scheme string
host 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) {
@@ -35,10 +47,12 @@ func New(cfg *config.Config) (*Client, error) {
if baseURL == "" {
return nil, errors.New("server url is required")
}
agentToken := normalizeAgentToken(cfg.AgentAPIToken)
cert, err := tls.LoadX509KeyPair(cfg.ClientCertPath(), cfg.ClientKeyPath())
if err != nil {
return nil, fmt.Errorf("load client cert: %w", err)
}
clientCert := cert
caPool, err := x509.SystemCertPool()
if err != nil || caPool == nil {
caPool = x509.NewCertPool()
@@ -57,6 +71,12 @@ func New(cfg *config.Config) (*Client, error) {
Certificates: []tls.Certificate{cert},
RootCAs: caPool,
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{
@@ -97,22 +117,32 @@ func New(cfg *config.Config) (*Client, error) {
scheme: scheme,
host: host,
addr: addr,
token: agentToken,
}, nil
}
func (c *Client) setAuth(req *http.Request) {
if c.token == "" {
return
}
req.Header.Set("Authorization", "Bearer "+c.token)
}
type EnrollRequest struct {
Token string `json:"token"`
CSRPEM string `json:"csr_pem"`
Host string `json:"host"`
IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"`
AgentID string `json:"agent_id,omitempty"`
Token string `json:"token"`
CSRPEM string `json:"csr_pem"`
Host string `json:"host"`
IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"`
ServerID string `json:"server_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
}
type EnrollResponse struct {
ServerID string `json:"server_id"`
ClientCert string `json:"client_cert_pem"`
CACert string `json:"ca_cert_pem"`
AgentToken string `json:"agent_api_token,omitempty"`
SyncProfile string `json:"sync_profile,omitempty"`
DisplayName string `json:"display_name,omitempty"`
}
@@ -149,6 +179,19 @@ type SyncReportRequest struct {
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) {
baseURL := strings.TrimRight(serverURL, "/")
if baseURL == "" {
@@ -241,6 +284,7 @@ func (c *Client) FetchAccountAccess(ctx context.Context, serverID string) ([]Acc
if err != nil {
return nil, fmt.Errorf("build account access request: %w", err)
}
c.setAuth(req)
resp, err := c.http.Do(req)
if err != nil {
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 {
return nil, fmt.Errorf("build user ca request: %w", err)
}
c.setAuth(req)
resp, err := c.http.Do(req)
if err != nil {
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)
}
req.Header.Set("Content-Type", "application/json")
c.setAuth(req)
resp, err := c.http.Do(req)
if err != nil {
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
}
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 {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/agent/servers/"+serverID+"/logs", bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("build log request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
c.setAuth(req)
resp, err := c.http.Do(req)
if err != nil {
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)
}
req.Header.Set("Content-Type", "application/json")
c.setAuth(req)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("send host update: %w", err)
+97 -1
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"strconv"
"strings"
)
@@ -26,12 +27,28 @@ type AccountPolicy struct {
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 {
ServerURL string `json:"server_url"`
ServerID string `json:"server_id,omitempty"`
AgentAPIToken string `json:"agent_api_token,omitempty"`
ServerCAPath string `json:"server_ca_path,omitempty"`
SyncIntervalSeconds int `json:"sync_interval_seconds,omitempty"`
LogBatchSize int `json:"log_batch_size,omitempty"`
LogSources []LogSource `json:"log_sources,omitempty"`
StateDir string `json:"state_dir,omitempty"`
AccountPolicy AccountPolicy `json:"account_policy,omitempty"`
}
@@ -48,7 +65,11 @@ func LoadOrInit(path string, serverURL string) (*Config, error) {
if serverURL == "" {
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)
if err := validate(cfg, false); err != nil {
return nil, err
@@ -62,9 +83,15 @@ func LoadOrInit(path string, serverURL string) (*Config, error) {
if err := json.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
envAgentToken := strings.TrimSpace(os.Getenv("KEYWARDEN_AGENT_API_TOKEN"))
if cfg.ServerCAPath == "" {
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)
if err := validate(cfg, false); err != nil {
return nil, err
@@ -108,6 +135,8 @@ func applyDefaults(cfg *Config) {
}
func validate(cfg *Config, requireServerID bool) error {
cfg.ServerID = strings.TrimSpace(cfg.ServerID)
cfg.AgentAPIToken = strings.TrimSpace(cfg.AgentAPIToken)
var missing []string
if cfg.ServerURL == "" {
missing = append(missing, "server_url")
@@ -118,12 +147,57 @@ func validate(cfg *Config, requireServerID bool) error {
if len(missing) > 0 {
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 {
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
}
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 {
return c.StateDir + "/agent.crt"
}
@@ -144,6 +218,28 @@ func (c *Config) LogSpoolDir() string {
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 {
if idx := strings.LastIndex(path, string(os.PathSeparator)); idx != -1 {
return path[:idx]
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")
}
}
+35
View File
@@ -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)
}
+35
View File
@@ -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)
}
+34 -13
View File
@@ -3,21 +3,42 @@ package logs
import "time"
type Event struct {
Timestamp string `json:"timestamp"`
Category string `json:"category"`
EventType string `json:"event_type"`
Unit string `json:"unit,omitempty"`
Priority string `json:"priority,omitempty"`
Hostname string `json:"hostname,omitempty"`
Username string `json:"username,omitempty"`
Principal string `json:"principal,omitempty"`
SourceIP string `json:"source_ip,omitempty"`
SessionID string `json:"session_id,omitempty"`
Message string `json:"message,omitempty"`
Raw string `json:"raw,omitempty"`
Fields map[string]string `json:"fields,omitempty"`
Timestamp string `json:"timestamp"`
Category string `json:"category"`
EventType string `json:"event_type"`
SourceKind string `json:"source_kind,omitempty"`
SourceName string `json:"source_name,omitempty"`
Unit string `json:"unit,omitempty"`
Priority string `json:"priority,omitempty"`
Hostname string `json:"hostname,omitempty"`
Username string `json:"username,omitempty"`
Principal string `json:"principal,omitempty"`
SourceIP string `json:"source_ip,omitempty"`
SessionID string `json:"session_id,omitempty"`
Message string `json:"message,omitempty"`
Raw string `json:"raw,omitempty"`
Fields map[string]string `json:"fields,omitempty"`
}
func NewEvent(ts time.Time) Event {
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.
+61
View File
@@ -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
+13
View File
@@ -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=
+23
View File
@@ -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
+6
View File
@@ -25,6 +25,8 @@ class AccessRequestAdmin(GuardedModelAdmin):
"request_shell",
"request_logs",
"request_users",
"requested_duration_hours",
"requested_server_username",
"requested_at",
"expires_at",
"decided_by",
@@ -56,6 +58,8 @@ class AccessRequestAdmin(GuardedModelAdmin):
"request_shell",
"request_logs",
"request_users",
"requested_duration_hours",
"requested_server_username",
"expires_at",
)
},
@@ -73,6 +77,8 @@ class AccessRequestAdmin(GuardedModelAdmin):
"request_shell",
"request_logs",
"request_users",
"requested_duration_hours",
"requested_server_username",
"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),
),
]
+2
View File
@@ -31,6 +31,8 @@ class AccessRequest(models.Model):
request_shell = models.BooleanField(default=False)
request_logs = 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)
decided_at = models.DateTimeField(null=True, blank=True)
expires_at = models.DateTimeField(null=True, blank=True)
+1
View File
@@ -17,6 +17,7 @@ def sync_server_view_perm(access_request: AccessRequest) -> None:
server_id=access_request.server_id,
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))
.exists()
)
+18 -1
View File
@@ -3,7 +3,7 @@ from django.contrib import admin
from django.utils import timezone
from unfold.admin import ModelAdmin
from .models import ErasureRequest, ExternalIdentity
from .models import ErasureRequest, ExternalIdentity, NativeAccountSecurity
class ErasureRequestAdminForm(forms.ModelForm):
@@ -89,3 +89,20 @@ class ExternalIdentityAdmin(ModelAdmin):
def has_delete_permission(self, request, obj=None) -> bool:
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
+46
View File
@@ -1,4 +1,50 @@
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):
@@ -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",
},
),
]
+19
View File
@@ -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 Status(models.TextChoices):
PENDING = "pending", "Pending"
+90 -33
View File
@@ -2,16 +2,14 @@ from __future__ import annotations
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError
from django.shortcuts import redirect
from django.urls import reverse
from allauth.exceptions import ImmediateHttpResponse
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from apps.core.rbac import ROLE_USER, get_user_role, set_user_role
from .identity import (
build_unique_username,
normalize_email,
@@ -23,8 +21,11 @@ from .models import ExternalIdentity
class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
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)
provider = sociallogin.account.provider
subject = str(sociallogin.account.uid or "").strip()
if not subject:
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):
self._reject(request, "Login failed: provider email is not verified.")
if sociallogin.is_existing:
if normalize_email(sociallogin.user.email) != email:
raise PermissionDenied("Social login email does not match enrolled account email.")
self._sync_identity(sociallogin.user, sociallogin)
if self._is_connect_flow(request, sociallogin):
self._handle_connect_flow(
request,
sociallogin,
provider=provider,
subject=subject,
)
return
existing_link = (
@@ -50,17 +54,19 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
)
.first()
)
if existing_link:
if normalize_email(existing_link.user.email) != email:
raise PermissionDenied("Linked social account email mismatch.")
sociallogin.connect(request, existing_link.user)
self._sync_identity(existing_link.user, sociallogin)
return
if not existing_link:
self._reject(
request,
"GitHub is not linked for this account. Sign in with your existing method and link GitHub from Profile.",
)
user = get_user_model().objects.filter(email__iexact=email).first()
if user:
sociallogin.connect(request, user)
self._sync_identity(user, sociallogin)
if sociallogin.is_existing:
if getattr(sociallogin.user, "pk", None) != existing_link.user_id:
raise PermissionDenied("GitHub identity is already linked to another Keywarden account.")
else:
sociallogin.connect(request, existing_link.user)
self._sync_identity(existing_link.user, sociallogin)
def populate_user(self, request, sociallogin, data):
user = super().populate_user(request, sociallogin, data)
@@ -78,15 +84,7 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
return user
def save_user(self, request, sociallogin, form=None):
user = super().save_user(request, sociallogin, form=form)
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
raise PermissionDenied("Social auto-onboarding is disabled. Link GitHub from your profile.")
def _sync_identity(self, user, sociallogin) -> None:
extra_data = dict(sociallogin.account.extra_data or {})
@@ -109,11 +107,67 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
except IntegrityError as 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:
user_email = normalize_email(getattr(sociallogin.user, "email", ""))
if user_email:
return user_email
return normalize_email((sociallogin.account.extra_data or {}).get("email"))
provider_email = normalize_email((sociallogin.account.extra_data or {}).get("email"))
if provider_email:
return provider_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:
normalized = normalize_email(email)
@@ -130,6 +184,9 @@ class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter):
return True
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)
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
</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>
{% 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
+217 -49
View File
@@ -3,6 +3,16 @@
{% block title %}Profile • Keywarden{% endblock %}
{% 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="space-y-6">
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
@@ -205,6 +215,100 @@
</div>
<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">
<div class="space-y-2">
<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">
<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>
<span class="text-xs text-gray-500">GitHub, Google, Apple and more</span>
<h3 class="text-sm font-semibold text-gray-900">GitHub</h3>
{% 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 class="divide-y divide-gray-100">
{% for provider in social_provider_cards %}
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-900">{{ provider.name }}</p>
{% if provider.linked and provider.identity %}
<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 class="space-y-3 p-4">
{% if github_identity %}
<dl class="grid grid-cols-1 gap-2 text-xs text-gray-600">
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Linked email</dt>
<dd>{{ github_identity.email_at_link }}</dd>
</div>
<div class="flex items-center gap-2">
{% if provider.linked %}
<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 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 class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Subject</dt>
<dd class="break-all font-mono">{{ github_identity.subject }}</dd>
</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>
{% 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>
</article>
</div>
@@ -339,11 +458,9 @@
<script>
(function () {
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
function getCsrfToken() {
if (typeof window.keywardenGetCsrfToken === "function") {
return window.keywardenGetCsrfToken();
}
return "";
}
@@ -366,7 +483,7 @@
if (!window.confirm("Regenerate the certificate for this key?")) {
return;
}
var csrf = getCookie("csrftoken");
var csrf = getCsrfToken();
fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST",
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]");
for (var i = 0; i < downloadButtons.length; i += 1) {
downloadButtons[i].addEventListener("click", handleDownload);
@@ -393,6 +535,32 @@
for (var j = 0; j < buttons.length; j += 1) {
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>
{% endblock %}
+368 -3
View File
@@ -1,11 +1,30 @@
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.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.models import ExternalIdentity
from apps.accounts.forms import NativePasswordResetForm
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):
@@ -19,6 +38,20 @@ class IdentityHelpersTests(TestCase):
self.assertNotEqual(generated, "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):
def test_email_change_is_blocked(self):
@@ -42,3 +75,335 @@ class ExternalIdentityTests(TestCase):
email_at_link="charlie@example.com",
)
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())
+9 -1
View File
@@ -6,6 +6,14 @@ app_name = "accounts"
urlpatterns = [
path("login/", views.login_view, name="login"),
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"),
]
+277 -58
View File
@@ -1,17 +1,76 @@
from datetime import timedelta
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import logout
from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required
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.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 apps.keys.certificates import issue_certificate_for_key
from apps.keys.models import SSHKey
from .forms import ErasureRequestForm, SSHKeyForm
from .models import ErasureRequest, ExternalIdentity
from .forms import ErasureRequestForm, NativePasswordResetForm, SSHKeyForm, StyledSetPasswordForm
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:
@@ -62,7 +121,85 @@ def profile(request):
key_form.add_error("public_key", "Key already exists.")
except Exception:
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()
erasure_form = ErasureRequestForm(request.POST)
if erasure_form.is_valid():
@@ -74,70 +211,53 @@ def profile(request):
reason=erasure_form.cleaned_data["reason"].strip(),
)
return redirect("accounts:profile")
else:
key_form = SSHKeyForm()
erasure_form = ErasureRequestForm()
else:
erasure_form = ErasureRequestForm()
key_form = SSHKeyForm()
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")
has_sso_identity = external_identities.exists()
oidc_identity = external_identities.filter(provider_type=ExternalIdentity.ProviderType.OIDC).first()
social_identity_map = {
identity.provider_id.lower(): identity
for identity in external_identities.filter(provider_type=ExternalIdentity.ProviderType.SOCIAL)
}
configured_social_map = {
provider.get("id", "").strip().lower(): provider
for provider in getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", [])
if provider.get("id")
}
github_identity = external_identities.filter(
provider_type=ExternalIdentity.ProviderType.SOCIAL,
provider_id__iexact="github",
).first()
github_provider = next(
(
provider
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 = [
("github", "GitHub"),
("google", "Google"),
("apple", "Apple"),
("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,
}
)
native_security = None
if not has_sso_identity:
native_security, _ = NativeAccountSecurity.objects.get_or_create(user=request.user)
idp_account_portal_url = _idp_account_portal_url()
context = {
"user": request.user,
"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_provider_id": getattr(settings, "KEYWARDEN_OIDC_PROVIDER_ID", "oidc"),
"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_form": erasure_form,
"key_form": key_form,
@@ -149,30 +269,39 @@ def profile(request):
def login_view(request):
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/")
has_sso_options = bool(
getattr(settings, "KEYWARDEN_OIDC_ENABLED", False)
or getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", [])
oidc_login_enabled
or social_providers
)
show_native_login = (
request.method == "POST"
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
return auth_views.LoginView.as_view(
template_name="accounts/login.html",
extra_context={
"social_providers": getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", []),
"oidc_enabled": bool(getattr(settings, "KEYWARDEN_OIDC_ENABLED", False)),
"social_providers": social_providers,
"oidc_enabled": oidc_login_enabled,
"oidc_login_url": "/oidc/authenticate/",
"oidc_provider_label": _oidc_provider_label(),
"show_native_login": show_native_login,
"can_use_native_login": auth_mode in {"native", "hybrid"},
"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)
@@ -180,3 +309,93 @@ def login_view(request):
def logout_view(request):
logout(request)
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.test import RequestFactory, TestCase
from apps.audit.admin import AuditEventTypeAdminForm
from apps.audit.matching import find_matching_event_type
from apps.audit.middleware import ApiAuditLogMiddleware
from apps.audit.models import AuditEventType, AuditLog
@@ -84,3 +85,58 @@ class AuditEventMatchingTests(TestCase):
)
self.assertIsNotNone(matched)
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")
+60
View File
@@ -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
+1 -1
View File
@@ -41,7 +41,7 @@ def issue_certificate_for_key(key: SSHKey, created_by=None) -> SSHCertificate:
safe_name = _sanitize_label(key.name or "key")
identity = f"keywarden-cert-{key.user_id}-{safe_name}-{key.id}"
cert_text = _sign_public_key(
ca_private_key=ca.private_key,
ca_private_key=ca.get_private_key(),
ca_public_key=ca.public_key,
public_key=key.public_key,
identity=identity,
+24 -2
View File
@@ -12,6 +12,8 @@ from django.core.exceptions import ValidationError
from django.db import models
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]:
trimmed = (public_key or "").strip()
@@ -104,8 +106,28 @@ class SSHCertificateAuthority(models.Model):
self.is_active = False
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:
if self.public_key and self.private_key:
if self.public_key and self.get_private_key():
if not self.fingerprint:
_, _, fingerprint = parse_public_key(self.public_key)
self.fingerprint = fingerprint
@@ -130,7 +152,7 @@ class SSHCertificateAuthority(models.Model):
except subprocess.CalledProcessError as 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:
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:
self.public_key = handle.read().strip()
_, _, fingerprint = parse_public_key(self.public_key)
+166 -3
View File
@@ -1,15 +1,48 @@
from django.contrib import admin
from django.conf import settings
from django.utils.html import format_html
try:
from unfold.admin import ModelAdmin as UnfoldModelAdmin
except ImportError:
UnfoldModelAdmin = admin.ModelAdmin
try:
from unfold.contrib.guardian.admin import GuardedModelAdmin
except ImportError: # Fallback for older Unfold builds without guardian admin shim.
from guardian.admin import GuardedModelAdmin as GuardianGuardedModelAdmin
from unfold.admin import ModelAdmin as UnfoldModelAdmin
class GuardedModelAdmin(GuardianGuardedModelAdmin, UnfoldModelAdmin):
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)
@@ -29,6 +62,68 @@ class ServerAdmin(GuardedModelAdmin):
"created_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):
if obj.image_url:
@@ -46,13 +141,20 @@ class ServerAdmin(GuardedModelAdmin):
@admin.register(EnrollmentToken)
class EnrollmentTokenAdmin(admin.ModelAdmin):
class EnrollmentTokenAdmin(UnfoldModelAdmin):
list_display = ("token", "created_at", "expires_at", "used_at", "server")
list_filter = ("created_at", "used_at")
search_fields = ("token", "server__display_name", "server__hostname")
readonly_fields = ("token", "created_at", "used_at", "server", "created_by")
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:
if not obj.pk:
obj.ensure_token()
@@ -92,3 +194,64 @@ class AgentCertificateAuthorityAdmin(admin.ModelAdmin):
for ca in queryset:
ca.revoke()
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
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import os
import secrets
import shlex
import shutil
import subprocess
import tempfile
@@ -34,8 +35,9 @@ class ShellConsumer(AsyncWebsocketConsumer):
self.tempdir = None
self.tempdir_path = ""
self.ssh_started = False
self.key_cleanup_task = None
self.key_paths = []
self.ssh_output_seen = False
self.key_path = ""
self.cert_path = ""
self.system_username = ""
self.shell_target = ""
self.server_id: int | None = None
@@ -91,9 +93,6 @@ class ShellConsumer(AsyncWebsocketConsumer):
if self.reader_task:
self.reader_task.cancel()
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:
self.proc.terminate()
try:
@@ -101,8 +100,9 @@ class ShellConsumer(AsyncWebsocketConsumer):
except asyncio.TimeoutError:
self.proc.kill()
if self.tempdir_path and self.ssh_started:
shutil.rmtree(self.tempdir_path, ignore_errors=True)
self.tempdir_path = ""
if not _is_truthy(getattr(settings, "KEYWARDEN_SHELL_PRESERVE_TMP", False)):
shutil.rmtree(self.tempdir_path, ignore_errors=True)
self.tempdir_path = ""
self.tempdir = None
async def receive(self, text_data=None, bytes_data=None):
@@ -131,38 +131,16 @@ class ShellConsumer(AsyncWebsocketConsumer):
user,
self.system_username,
)
self.key_path = key_path
self.cert_path = cert_path
ssh_host = _format_ssh_host(self.shell_target)
# Use a locked-down, non-interactive SSH invocation suitable for websockets.
command = [
"ssh",
"-tt",
"-i",
key_path,
"-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",
]
# Build a locked-down SSH invocation and explicitly launch a remote shell.
command = _build_ssh_command(
key_path=key_path,
cert_path=cert_path,
username=self.system_username,
host=ssh_host,
)
self.proc = await asyncio.create_subprocess_exec(
*command,
stdin=asyncio.subprocess.PIPE,
@@ -170,8 +148,6 @@ class ShellConsumer(AsyncWebsocketConsumer):
stderr=asyncio.subprocess.STDOUT,
)
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())
async def _stream_output(self):
@@ -182,21 +158,104 @@ class ShellConsumer(AsyncWebsocketConsumer):
chunk = await self.proc.stdout.read(4096)
if not chunk:
break
self.ssh_output_seen = True
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()
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:
await asyncio.sleep(delay_seconds)
for path in self.key_paths:
try:
os.remove(path)
except FileNotFoundError:
continue
except Exception:
pass
finally:
self.key_paths = []
proc = await asyncio.create_subprocess_exec(
"ssh-keygen",
"-L",
"-f",
self.cert_path,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
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
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:
public_key = handle.read().strip()
cert_text = _sign_public_key(
ca_private_key=ca.private_key,
ca_private_key=ca.get_private_key(),
ca_public_key=ca.public_key,
public_key=public_key,
identity=identity,
@@ -310,3 +369,143 @@ def _format_ssh_host(host: str) -> str:
if ":" in host and not (host.startswith("[") and host.endswith("]")):
return f"[{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
View File
@@ -12,6 +12,8 @@ from django.core.validators import RegexValidator
from django.db import models
from django.utils import timezone
from apps.core.secret_files import looks_like_pem, read_secret_ref, write_secret_file
hostname_validator = RegexValidator(
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_cert_fingerprint = models.CharField(max_length=128, 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_ping_ms = models.PositiveIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -135,8 +138,28 @@ class AgentCertificateAuthority(models.Model):
self.is_active = False
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:
if self.cert_pem and self.key_pem:
if self.cert_pem and self.get_key_pem():
return
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, self.name)])
@@ -159,7 +182,7 @@ class AgentCertificateAuthority(models.Model):
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
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.serial = format(cert.serial_number, "x")
@@ -189,3 +212,84 @@ class ServerAccount(models.Model):
def __str__(self) -> str:
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}"
+65 -10
View File
@@ -6,18 +6,73 @@ from django.utils import timezone
from apps.access.models import AccessRequest
def user_can_shell(user, server, now=None) -> bool:
if user.has_perm("servers.shell_server", server):
return True
def _active_access_requests(user, server, now):
return AccessRequest.objects.filter(
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:
now = timezone.now()
if _is_admin_override(user) or _has_manual_view_access(user, server, now):
return True
return (
AccessRequest.objects.filter(
requester=user,
server=server,
status=AccessRequest.Status.APPROVED,
request_shell=True,
)
.filter(Q(expires_at__isnull=True) | Q(expires_at__gt=now))
_active_access_requests(user, server, now)
.filter(Q(request_shell=True) | Q(request_logs=True) | Q(request_users=True))
.exists()
)
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()
)
@@ -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>
+32 -18
View File
@@ -85,18 +85,22 @@
</div>
<nav class="mt-4 flex flex-wrap gap-2 border-b border-gray-200 pb-3 text-sm font-medium text-gray-500">
<a
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>
<a
href="{% url 'servers:audit' server.id %}"
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>
{% if can_users %}
<a
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>
{% endif %}
{% if can_logs %}
<a
href="{% url 'servers:audit' server.id %}"
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 %}
<a
href="{% url 'servers:shell' server.id %}"
@@ -105,10 +109,20 @@
Shell
</a>
{% endif %}
<a
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>
{% if can_users %}
<a
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>
{% 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>
@@ -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 %}
+643 -25
View File
@@ -1,46 +1,664 @@
{% extends "base.html" %}
{% load static tz %}
{% 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 %}
<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">
<div>
<h2 class="text-lg font-semibold text-gray-900">Audit logs</h2>
<p class="mt-1 text-sm text-gray-500">Track certificate issuance and access events.</p>
</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 class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
</div>
<p class="mt-3 text-sm text-gray-600">Logs will appear here once collection is enabled for this server.</p>
<section class="rounded-2xl border border-gray-200 bg-white p-3 shadow-sm">
<div class="flex flex-wrap items-center gap-2 text-sm font-medium text-gray-500">
<button
type="button"
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 %}"
data-audit-subpage="charts"
>
Charts
</button>
<button
type="button"
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 %}"
data-audit-subpage="logs"
>
Logs
</button>
</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">
<div>
<h2 class="text-lg font-semibold text-gray-900">Metrics</h2>
<p class="mt-1 text-sm text-gray-500">Monitor CPU, memory, and session activity.</p>
<h2 class="text-lg font-semibold text-gray-900">Audit workspace</h2>
<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>
<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 class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 12h18M7 8v8M17 8v8" />
</svg>
<dl class="mt-4 grid gap-4 text-xs text-gray-600 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt class="font-semibold 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>
<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>
</section>
</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 %}
+270 -83
View File
@@ -7,7 +7,7 @@
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<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>
<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
@@ -18,93 +18,140 @@
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{% 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">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<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 }}
{% if item.access.granted %}
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<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>
<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 class="relative">
<button
type="button"
data-tooltip-target="server-status-{{ item.server.id }}"
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 %}"
>
{{ item.status.label }}: {{ item.status.detail }}
</button>
<div
id="server-status-{{ item.server.id }}"
role="tooltip"
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="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Status</span>
<span class="font-medium text-gray-900">{{ item.status.label }}: {{ item.status.detail }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Ping</span>
<span class="font-medium text-gray-900">
{% if item.status.ping_ms is not None %}{{ item.status.ping_ms }}ms{% else %}—{% endif %}
</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Hostname</span>
<span class="font-medium text-gray-900">{{ item.server.hostname|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv4</span>
<span class="font-medium text-gray-900">{{ item.server.ipv4|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv6</span>
<span class="font-medium text-gray-900">{{ item.server.ipv6|default:"—" }}</span>
</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 class="relative">
<button
type="button"
data-tooltip-target="server-status-{{ item.server.id }}"
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 %}"
>
{{ item.status.label }}: {{ item.status.detail }}
</button>
<div
id="server-status-{{ item.server.id }}"
role="tooltip"
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="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Status</span>
<span class="font-medium text-gray-900">{{ item.status.label }}: {{ item.status.detail }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Ping</span>
<span class="font-medium text-gray-900">
{% if item.status.ping_ms is not None %}{{ item.status.ping_ms }}ms{% else %}—{% endif %}
</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">Hostname</span>
<span class="font-medium text-gray-900">{{ item.server.hostname|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv4</span>
<span class="font-medium text-gray-900">{{ item.server.ipv4|default:"—" }}</span>
</div>
<div class="flex items-center justify-between">
<span class="font-semibold text-gray-500">IPv6</span>
<span class="font-medium text-gray-900">{{ item.server.ipv6|default:"—" }}</span>
</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 class="tooltip-arrow" data-popper-arrow></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">
<dt>Access until</dt>
<dd class="font-medium text-gray-900">
{% if item.expires_at %}
{{ item.expires_at|date:"M j, Y H:i" }}
{% else %}
No expiry
{% endif %}
</dd>
<dl class="mt-5 divide-y divide-gray-100 text-sm text-gray-600">
<div class="flex items-center justify-between py-2">
<dt>Access until</dt>
<dd class="font-medium text-gray-900">
{% if item.expires_at %}
{{ item.expires_at|date:"M j, Y H:i" }}
{% else %}
No expiry
{% endif %}
</dd>
</div>
<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 class="flex items-center justify-between py-2">
<dt>Last accessed</dt>
<dd class="font-medium text-gray-900">
{% if item.last_accessed %}
{{ item.last_accessed|date:"M j, Y H:i" }}
{% else %}
—
{% endif %}
</dd>
{% else %}
<div class="flex items-start justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">{{ item.server.hostname|default:"Unassigned" }}</h2>
<p class="mt-1 text-xs text-gray-500">No access granted</p>
</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>
</div>
</dl>
<div class="mt-5 flex items-center justify-between border-t border-gray-100 pt-4 text-xs text-gray-500">
<span>Certificates and access</span>
<a href="{% url 'servers:detail' item.server.id %}" class="font-semibold text-blue-700 hover:underline">View details</a>
</div>
<div class="mt-5 border-t border-gray-100 pt-4">
{% if item.access.pending %}
<span class="inline-flex items-center rounded-lg bg-amber-100 px-3 py-1.5 text-xs font-semibold text-amber-800">
Request pending since {{ item.access.pending.requested_at|date:"M j, Y H:i" }}
</span>
{% 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>
{% endfor %}
</div>
@@ -116,9 +163,149 @@
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
</div>
<h2 class="mt-4 text-lg font-semibold text-gray-900">No server access yet</h2>
<p class="mt-2 text-sm text-gray-600">Request access to a server to see it listed here.</p>
<h2 class="mt-4 text-lg font-semibold text-gray-900">No servers yet</h2>
<p class="mt-2 text-sm text-gray-600">Create or enroll a server to start handling access requests.</p>
</div>
{% endif %}
</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"
>
&times;
</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 %}
@@ -129,11 +129,9 @@
</div>
<script>
(function () {
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
function getCsrfToken() {
if (typeof window.keywardenGetCsrfToken === "function") {
return window.keywardenGetCsrfToken();
}
return "";
}
@@ -156,7 +154,7 @@
if (!window.confirm("Regenerate the certificate for this key?")) {
return;
}
var csrf = getCookie("csrftoken");
var csrf = getCsrfToken();
fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST",
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 %}
+120 -11
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% load tz %}
{% block title %}Settings • {{ server.display_name }} • Keywarden{% endblock %}
@@ -7,22 +8,130 @@
{% 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">
<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">Settings</h2>
<p class="mt-1 text-sm text-gray-500">Manage server-level access policies and metadata.</p>
<h2 class="text-lg font-semibold text-gray-900">Server status</h2>
<p class="mt-1 text-sm text-gray-500">Current enrollment and telemetry posture for this host.</p>
</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 class="mt-5 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 text-blue-700">
<svg class="h-6 w-6" aria-hidden="true" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6l4 2" />
<circle cx="12" cy="12" r="9" stroke-width="1.5"></circle>
</svg>
<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>
<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>
<p class="mt-3 text-sm text-gray-600">Settings will appear here as server options are added.</p>
</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>
</div>
{% endblock %}
+43 -14
View File
@@ -148,11 +148,9 @@
<script src="{% static 'vendor/xterm/xterm.js' %}"></script>
<script>
(function () {
function getCookie(name) {
var value = "; " + document.cookie;
var parts = value.split("; " + name + "=");
if (parts.length === 2) {
return parts.pop().split(";").shift();
function getCsrfToken() {
if (typeof window.keywardenGetCsrfToken === "function") {
return window.keywardenGetCsrfToken();
}
return "";
}
@@ -175,7 +173,7 @@
if (!window.confirm("Regenerate the certificate for this key?")) {
return;
}
var csrf = getCookie("csrftoken");
var csrf = getCsrfToken();
fetch("/api/v1/keys/" + keyId + "/certificate", {
method: "POST",
credentials: "same-origin",
@@ -240,6 +238,7 @@
var startButton = document.getElementById("shell-start");
var activeSocket = null;
var activeTerm = null;
var activeResizeHandler = null;
var popoutShell = document.getElementById("shell-popout-shell");
var isPopout = {{ is_popout|yesno:"true,false" }};
@@ -283,19 +282,40 @@
startButton.classList.toggle("hover:bg-blue-800", !isRunning);
}
function stopTerminal() {
if (activeSocket) {
function disposeTerminal() {
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 {
activeSocket.close();
} catch (err) {
// noop
}
}
activeSocket = null;
if (termContainer) {
termContainer.dataset.started = "0";
}
activeSocket = null;
activeTerm = null;
if (opts.disposeTerminal) {
disposeTerminal();
}
setButtonState(false);
}
@@ -303,6 +323,7 @@
if (!termContainer || !window.Terminal || termContainer.dataset.started === "1") {
return;
}
disposeTerminal();
termContainer.dataset.started = "1";
if (startButton) {
startButton.disabled = true;
@@ -343,10 +364,18 @@
socket.onclose = function () {
term.write("\r\nSession closed.\r\n");
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) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data);
@@ -356,11 +385,11 @@
setButtonState(true);
if (isPopout) {
var onResize = function () {
activeResizeHandler = function () {
sizePopoutTerminal();
fitTerminal(term);
};
window.addEventListener("resize", onResize);
window.addEventListener("resize", activeResizeHandler);
}
}
@@ -373,7 +402,7 @@
if (startButton) {
startButton.addEventListener("click", function () {
if (termContainer.dataset.started === "1") {
stopTerminal();
stopTerminal({ disposeTerminal: true });
return;
}
startTerminal();
+1
View File
@@ -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")
+138
View File
@@ -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())
+611
View File
@@ -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"])
+398
View File
@@ -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)
+14
View File
@@ -6,8 +6,22 @@ app_name = "servers"
urlpatterns = [
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>/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>/settings/", views.settings, name="settings"),
]
+1088 -31
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -36,4 +36,12 @@ python manage.py collectstatic --noinput
python manage.py migrate --noinput
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
+26 -4
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timedelta
from typing import List, Optional
from django.http import HttpRequest
@@ -22,6 +22,8 @@ class AccessRequestCreateIn(Schema):
request_shell: bool = False
request_logs: 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
@@ -39,6 +41,8 @@ class AccessRequestOut(Schema):
request_shell: bool
request_logs: bool
request_users: bool
requested_duration_hours: Optional[int] = None
requested_server_username: str
requested_at: str
decided_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_logs=access_request.request_logs,
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(),
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,
@@ -128,6 +134,11 @@ def build_router() -> Router:
server = Server.objects.get(id=payload.server_id)
except Server.DoesNotExist:
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(
requester=request.user,
server=server,
@@ -135,6 +146,8 @@ def build_router() -> Router:
request_shell=payload.request_shell,
request_logs=payload.request_logs,
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:
access_request.expires_at = payload.expires_at
@@ -167,9 +180,9 @@ def build_router() -> Router:
Auth: required.
Permissions: requires `access.change_accessrequest` on the object.
Rules:
- Admin/operator (global change) can set status to approved/denied/revoked/cancelled and
update expires_at.
- Non-admin can only set status to cancelled, and only while pending.
- Users with global change permission can set status to
approved/denied/revoked/cancelled and update expires_at.
- Non-global editors can only set status to cancelled, and only while pending.
Side effects: updates object permissions for server visibility when
approvals or revocations occur.
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
else:
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()
sync_server_view_perm(access_request)
return _request_to_out(access_request)
+339 -38
View File
@@ -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 cryptography import x509
@@ -9,7 +10,9 @@ from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.validators import validate_ipv4_address, validate_ipv6_address
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.http import HttpRequest
from django.utils.dateparse import parse_datetime
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from ninja import Body, Router, Schema
@@ -17,6 +20,7 @@ from ninja.errors import HttpError
from pydantic import Field
from guardian.shortcuts import get_users_with_perms
from apps.access.models import AccessRequest
from apps.core.rbac import require_perms
from apps.keys.certificates import get_active_ca
from apps.keys.models import SSHKey
@@ -25,10 +29,13 @@ from apps.servers.models import (
AgentCertificateAuthority,
EnrollmentToken,
Server,
ServerAuditLog,
ServerAccount,
ServerLogSource,
hostname_validator,
)
from apps.telemetry.models import TelemetryEvent
from keywarden.api.security import AgentPrincipal, AgentRuntimeAuth, hash_agent_token
class AuthorizedKeyOut(Schema):
@@ -73,6 +80,7 @@ class SyncReportOut(Schema):
class AgentEnrollIn(Schema):
token: str
csr_pem: str
server_id: Optional[str] = None
host: Optional[str] = None
ipv4: Optional[str] = None
ipv6: Optional[str] = None
@@ -82,12 +90,15 @@ class AgentEnrollOut(Schema):
server_id: str
client_cert_pem: str
ca_cert_pem: str
agent_api_token: str
class LogEventIn(Schema):
timestamp: str
category: str
event_type: str
source_kind: Optional[str] = None
source_name: Optional[str] = None
unit: Optional[str] = None
priority: Optional[str] = None
hostname: Optional[str] = None
@@ -105,6 +116,19 @@ class LogIngestOut(Schema):
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):
host: Optional[str] = None
ipv4: Optional[str] = None
@@ -114,6 +138,7 @@ class AgentHeartbeatIn(Schema):
def build_router() -> Router:
router = Router()
agent_auth = AgentRuntimeAuth()
@router.post("/enroll", response=AgentEnrollOut, auth=None)
@csrf_exempt
@@ -124,7 +149,8 @@ def build_router() -> Router:
enrollment completes.
Inputs: enrollment token + CSR from the agent, optional host/IP hints.
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).
- Signs the CSR with the active Agent CA and returns client cert + CA.
Rationale: this is the only supported server onboarding flow. If this
@@ -151,39 +177,62 @@ def build_router() -> Router:
hostname = None
ipv4 = _normalize_ip(payload.ipv4, 4)
ipv6 = _normalize_ip(payload.ipv6, 6)
requested_server_id = _parse_requested_server_id(payload.server_id)
csr = _load_csr((payload.csr_pem or "").strip())
try:
with transaction.atomic():
server = Server.objects.create(
display_name=display_name,
hostname=hostname,
ipv4=ipv4,
ipv6=ipv6,
)
if requested_server_id is None:
server = Server.objects.create(
display_name=display_name,
hostname=hostname,
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.save(update_fields=["used_at", "server"])
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_cert_fingerprint = fingerprint
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:
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(
server_id=str(server.id),
client_cert_pem=cert_pem,
ca_cert_pem=ca_pem,
agent_api_token=agent_api_token,
)
@router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut])
def authorized_keys(request: HttpRequest, server_id: int):
"""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.
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.
Rationale: this is the policy enforcement point for per-user access.
"""
@@ -193,7 +242,7 @@ def build_router() -> Router:
"keys.view_sshkey",
)
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)
output: list[AuthorizedKeyOut] = []
for user in users:
@@ -209,16 +258,17 @@ def build_router() -> Router:
)
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):
"""List accounts that should exist on a server.
Auth: mTLS expected at the edge (no session/JWT).
Behavior: resolves active users with server object perms and their keys.
Auth: per-agent mTLS certificate presented to nginx and validated server-side.
Behavior: resolves active users with approved shell/users scopes.
Rationale: drives agent-side account provisioning.
"""
_require_agent_access(request, 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 [
AccountAccessOut(
user_id=user.id,
@@ -230,29 +280,31 @@ def build_router() -> Router:
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
def ssh_ca(request: HttpRequest, server_id: int):
"""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)
ca = get_active_ca()
if not ca.public_key:
raise HttpError(404, "SSH CA not configured")
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
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn = Body(...)):
"""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.
Rationale: provides an audit trail of enforcement actions without
requiring full log ingestion for every sync cycle.
"""
_require_agent_access(request, server_id)
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
@@ -273,34 +325,94 @@ def build_router() -> Router:
_update_server_accounts(server, payload.accounts)
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
def ingest_logs(request: HttpRequest, server_id: int, payload: List[LogEventIn] = Body(...)):
"""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.
Storage: raw logs are persisted separately per-server (SQLite shards),
not in the primary Postgres database.
Storage: events are persisted in the primary datastore and indexed per
server for filtering in the server audit view.
Rationale: this is the ingestion pipe for security audit logging.
"""
_require_agent_access(request, server_id)
try:
Server.objects.get(id=server_id)
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
raise HttpError(404, "Server not found")
# TODO: enqueue to Valkey and persist to SQLite slices.
return LogIngestOut(status="accepted", accepted=len(payload))
if not 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
def heartbeat(request: HttpRequest, server_id: int, payload: AgentHeartbeatIn = Body(...)):
"""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).
Conflict: unique constraints are enforced; conflicts return 409.
Rationale: keeps the server inventory accurate without manual edits.
"""
_require_agent_access(request, server_id)
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
@@ -343,17 +455,82 @@ def _get_server_or_404(server_id: int) -> Server:
raise HttpError(404, "Server not found")
def _resolve_access_users(server: Server) -> list:
users = list(
get_users_with_perms(
def _parse_requested_server_id(value: Optional[str]) -> Optional[int]:
text = str(value or "").strip()
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,
only_with_perms_in=["view_server"],
with_group_users=True,
with_superusers=False,
)
)
active = [user for user in users if getattr(user, "is_active", False)]
return sorted(active, key=lambda user: (user.username or "", user.id))
):
if not getattr(user, "is_active", False):
continue
# 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]]:
@@ -402,7 +579,7 @@ def _load_agent_ca() -> tuple[x509.Certificate, object, str]:
raise HttpError(500, "Agent CA not configured")
try:
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):
raise HttpError(500, "Invalid agent CA material")
return ca_cert, ca_key, ca.cert_pem
@@ -463,4 +640,128 @@ def _normalize_ip(value: Optional[str], version: int) -> Optional[str]:
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()
+3 -3
View File
@@ -15,7 +15,7 @@ from apps.core.rbac import ROLE_USER, get_user_role, require_perms, set_user_rol
class UserCreateIn(Schema):
email: EmailStr
password: str = Field(min_length=8)
role: Literal["administrator", "operator", "auditor", "user", "admin"]
role: Literal["administrator", "user", "admin"]
class UserListOut(Schema):
@@ -34,7 +34,7 @@ class UserDetailOut(Schema):
class UserUpdateIn(Schema):
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
@@ -55,7 +55,7 @@ def build_router() -> Router:
"""Create a platform user and assign a Keywarden role.
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
role which maps to Keywarden group permissions.
Rationale: enables automation and external admin workflows; mirrors
+263
View File
@@ -1,13 +1,52 @@
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 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.http import HttpRequest
from ninja.security import HttpBearer
from rest_framework_simplejwt.authentication import JWTAuthentication
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):
"""
@@ -28,3 +67,227 @@ class JWTAuth(HttpBearer):
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
View File
@@ -22,6 +22,12 @@ def env_csv(name: str, default: str = "") -> list[str]:
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
SECRET_KEY = os.getenv("KEYWARDEN_SECRET_KEY")
@@ -33,55 +39,51 @@ CSRF_TRUSTED_ORIGINS = [
for origin in os.getenv("KEYWARDEN_TRUSTED_ORIGINS", "").split(",")
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_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",
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_ENABLED
and KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID
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_GOOGLE_CONFIGURED,
KEYWARDEN_SOCIAL_GITHUB_CONFIGURED,
KEYWARDEN_SOCIAL_APPLE_CONFIGURED,
)
KEYWARDEN_SOCIAL_AUTH_ENABLED = (
KEYWARDEN_AUTH_MODE_REQUESTED == "hybrid"
and KEYWARDEN_SOCIAL_GITHUB_CONFIGURED
)
# Default to secure cookies and respect TLS termination headers.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = True
SESSION_COOKIE_HTTPONLY = True
INSTALLED_APPS = [
"unfold.contrib.guardian",
@@ -120,12 +122,8 @@ if KEYWARDEN_SOCIAL_AUTH_ENABLED:
"allauth.socialaccount",
]
)
if KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED:
INSTALLED_APPS.append("allauth.socialaccount.providers.google")
if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
INSTALLED_APPS.append("allauth.socialaccount.providers.github")
if KEYWARDEN_SOCIAL_APPLE_CONFIGURED:
INSTALLED_APPS.append("allauth.socialaccount.providers.apple")
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
@@ -180,6 +178,9 @@ CHANNEL_LAYERS = {
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
}
TIME_ZONE = "UTC"
USE_TZ = True
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
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_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_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", "{{username}}_{{user_id}}"
)
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_RESULT_BACKEND = os.getenv("KEYWARDEN_CELERY_RESULT_BACKEND", REDIS_URL)
@@ -216,6 +240,8 @@ PASSWORD_HASHERS = [
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR/"static"
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Avoid wildcard CORS on static assets (Access-Control-Allow-Origin: *).
WHITENOISE_ALLOW_ALL_ORIGINS = False
TAILWIND_APP_NAME="theme"
@@ -265,7 +291,8 @@ UNFOLD = {
# Force a consistent admin theme; disables theme switching.
"THEME": "dark", # Force theme: "dark" or "light". Will disable theme switcher
"SIDEBAR": {
"show_search": True,
"show_search": False,
"command_search": False,
"show_all_applications": True,
"navigation": [
{
@@ -284,7 +311,7 @@ UNFOLD = {
"STYLES": [
"/static/unfold/css/styles.css",
"/static/unfold/css/simplebar.css",
#(lambda request: "/static/unfold/css/keywarden.css"),
"/static/unfold/css/keywarden.css",
],
# "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_SCOPES = os.getenv("KEYWARDEN_OIDC_SCOPES", "openid email profile")
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_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_USERNAME_CLAIM = os.getenv("KEYWARDEN_OIDC_USERNAME_CLAIM", "preferred_username")
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_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 = []
if KEYWARDEN_SOCIAL_GOOGLE_CONFIGURED:
if KEYWARDEN_SOCIAL_AUTH_ENABLED and KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append(
{"id": "google", "name": "Google", "login_url": "/accounts/sso/google/login/"}
)
if KEYWARDEN_SOCIAL_GITHUB_CONFIGURED:
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append(
{"id": "github", "name": "GitHub", "login_url": "/accounts/sso/github/login/"}
)
if KEYWARDEN_SOCIAL_APPLE_CONFIGURED:
KEYWARDEN_SOCIAL_LOGIN_PROVIDERS.append(
{"id": "apple", "name": "Apple", "login_url": "/accounts/sso/apple/login/"}
{
"id": "github",
"name": "GitHub",
"login_url": "/accounts/sso/github/login/",
"connect_url": "/accounts/sso/github/login/?process=connect",
}
)
if KEYWARDEN_SOCIAL_AUTH_ENABLED:
@@ -378,24 +408,14 @@ if KEYWARDEN_SOCIAL_AUTH_ENABLED:
ACCOUNT_UNIQUE_EMAIL = True
ACCOUNT_USERNAME_REQUIRED = False
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_ADAPTER = "apps.accounts.social_auth.KeywardenSocialAccountAdapter"
SOCIALACCOUNT_LOGIN_ON_GET = True
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:
SOCIALACCOUNT_PROVIDERS["github"] = {
"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",
default=True,
)
# Auth mode: native | oidc | hybrid
AUTH_MODE = os.getenv("KEYWARDEN_AUTH_MODE", "hybrid").lower()
if AUTH_MODE not in {"native", "oidc", "hybrid"}:
AUTH_MODE = "hybrid"
AUTH_MODE = KEYWARDEN_AUTH_MODE_REQUESTED
if AUTH_MODE == "oidc" and not KEYWARDEN_OIDC_ENABLED:
AUTH_MODE = "native"
KEYWARDEN_AUTH_MODE = AUTH_MODE
AUTHENTICATION_BACKENDS = [
"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")
if AUTH_MODE == "oidc":
AUTHENTICATION_BACKENDS = [
"apps.accounts.oidc.KeywardenOIDCAuthenticationBackend",
"guardian.backends.ObjectPermissionBackend",
]
elif AUTH_MODE == "hybrid":
AUTHENTICATION_BACKENDS = [
"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":
# OIDC-only: enforce identity provider logins.
LOGIN_URL = "/oidc/authenticate/"
else:
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/"
LOGOUT_REDIRECT_URL = "/"
+3 -1
View File
@@ -7,9 +7,11 @@ from keywarden.api import api as ninja_api, api_v1 as ninja_api_v1
urlpatterns = [
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):
urlpatterns.append(path("accounts/sso/", include("allauth.urls")))
+1
View File
@@ -0,0 +1 @@
+182
View File
@@ -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}`);
+7 -1
View File
@@ -1,4 +1,10 @@
#!/bin/sh
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
+20
View File
@@ -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
}
}
})()
+12
View File
@@ -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.
+7
View File
@@ -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;
}
+93
View File
@@ -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.
+67
View File
@@ -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.
+13
View File
@@ -97,3 +97,16 @@ html:not(.dark) .kw-pill {
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 -1
View File
@@ -1,2 +1,2 @@
/*! 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
+45 -2
View File
@@ -21,7 +21,35 @@ window.addEventListener("load", (e) => {
*************************************************************/
function theme(defaultTheme = "auto") {
return {
openModal: false,
filterOpen: false,
openAllApplications: false,
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) {
this.adminTheme = theme;
},
@@ -351,7 +379,7 @@ function searchCommand() {
this.scrollToActiveItem();
}
},
selectItem(addHistory) {
selectItem(addHistory, openInNewTab=false) {
const link = this.items[this.currentIndex - 1].querySelector("a");
const data = {
title: link.dataset.title,
@@ -364,7 +392,11 @@ function searchCommand() {
this.addToHistory(data);
}
window.location = link.href;
if (openInNewTab) {
window.open(link.href, "_blank");
} else {
window.location = link.href;
}
},
addToHistory(data) {
let commandHistory = JSON.parse(
@@ -495,6 +527,15 @@ function dateTimeShortcutsOverlay() {
for (const mutationRecord of mutations) {
const display = mutationRecord.target.style.display;
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") {
overlay.style.display = "block";
@@ -517,6 +558,8 @@ function dateTimeShortcutsOverlay() {
childList: 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