object‑permission–driven server access; agent‑managed account provisioning with presence reporting
This commit is contained in:
59
app/apps/servers/migrations/0004_server_account.py
Normal file
59
app/apps/servers/migrations/0004_server_account.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("servers", "0003_agent_ca"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ServerAccount",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("system_username", models.CharField(max_length=128)),
|
||||
("is_present", models.BooleanField(db_index=True, default=False)),
|
||||
("last_synced_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
|
||||
("created_at", models.DateTimeField(default=django.utils.timezone.now, editable=False)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"server",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="accounts",
|
||||
to="servers.server",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="server_accounts",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Server account",
|
||||
"verbose_name_plural": "Server accounts",
|
||||
"ordering": ["server_id", "user_id"],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="serveraccount",
|
||||
constraint=models.UniqueConstraint(fields=("server", "user"), name="unique_server_account"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="serveraccount",
|
||||
index=models.Index(fields=["server", "user"], name="servers_account_user_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="serveraccount",
|
||||
index=models.Index(fields=["server", "is_present"], name="servers_account_present_idx"),
|
||||
),
|
||||
]
|
||||
@@ -157,3 +157,30 @@ class AgentCertificateAuthority(models.Model):
|
||||
self.key_pem = key_pem
|
||||
self.fingerprint = cert.fingerprint(hashes.SHA256()).hex()
|
||||
self.serial = format(cert.serial_number, "x")
|
||||
|
||||
|
||||
class ServerAccount(models.Model):
|
||||
server = models.ForeignKey(Server, on_delete=models.CASCADE, related_name="accounts")
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="server_accounts"
|
||||
)
|
||||
system_username = models.CharField(max_length=128)
|
||||
is_present = models.BooleanField(default=False, db_index=True)
|
||||
last_synced_at = models.DateTimeField(default=timezone.now, editable=False)
|
||||
created_at = models.DateTimeField(default=timezone.now, editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Server account"
|
||||
verbose_name_plural = "Server accounts"
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["server", "user"], name="unique_server_account")
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["server", "user"], name="servers_account_user_idx"),
|
||||
models.Index(fields=["server", "is_present"], name="servers_account_present_idx"),
|
||||
]
|
||||
ordering = ["server_id", "user_id"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.system_username} ({self.server_id})"
|
||||
|
||||
@@ -33,6 +33,18 @@
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<dt>Account on server</dt>
|
||||
<dd class="font-medium text-gray-900">
|
||||
{% if account_present is None %}
|
||||
Unknown
|
||||
{% elif account_present %}
|
||||
Present
|
||||
{% else %}
|
||||
Missing
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<dt>Last accessed</dt>
|
||||
<dd class="font-medium text-gray-900">
|
||||
|
||||
@@ -5,24 +5,21 @@ from django.db.models import Q
|
||||
from django.http import Http404
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from guardian.shortcuts import get_objects_for_user, get_perms
|
||||
|
||||
from apps.access.models import AccessRequest
|
||||
from apps.servers.models import Server
|
||||
from apps.servers.models import Server, ServerAccount
|
||||
|
||||
|
||||
@login_required(login_url="/accounts/login/")
|
||||
def dashboard(request):
|
||||
now = timezone.now()
|
||||
if request.user.has_perm("servers.view_server"):
|
||||
server_qs = Server.objects.all()
|
||||
else:
|
||||
server_qs = get_objects_for_user(
|
||||
request.user,
|
||||
"servers.view_server",
|
||||
klass=Server,
|
||||
accept_global_perms=False,
|
||||
)
|
||||
server_qs = get_objects_for_user(
|
||||
request.user,
|
||||
"servers.view_server",
|
||||
klass=Server,
|
||||
accept_global_perms=False,
|
||||
)
|
||||
|
||||
access_qs = (
|
||||
AccessRequest.objects.select_related("server")
|
||||
@@ -66,9 +63,7 @@ def detail(request, server_id: int):
|
||||
server = Server.objects.get(id=server_id)
|
||||
except Server.DoesNotExist:
|
||||
raise Http404("Server not found")
|
||||
if not request.user.has_perm("servers.view_server", server) and not request.user.has_perm(
|
||||
"servers.view_server"
|
||||
):
|
||||
if "view_server" not in get_perms(request.user, server):
|
||||
raise Http404("Server not found")
|
||||
|
||||
access = (
|
||||
@@ -82,9 +77,13 @@ def detail(request, server_id: int):
|
||||
.first()
|
||||
)
|
||||
|
||||
account = ServerAccount.objects.filter(server=server, user=request.user).first()
|
||||
context = {
|
||||
"server": server,
|
||||
"expires_at": access.expires_at if access else None,
|
||||
"last_accessed": None,
|
||||
"account_present": account.is_present if account else None,
|
||||
"account_synced_at": account.last_synced_at if account else None,
|
||||
"system_username": account.system_username if account else None,
|
||||
}
|
||||
return render(request, "servers/detail.html", context)
|
||||
|
||||
@@ -5,20 +5,27 @@ from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
||||
from django.conf import settings
|
||||
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, models, transaction
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from ninja import Body, Router, Schema
|
||||
from ninja.errors import HttpError
|
||||
from pydantic import Field
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
|
||||
from apps.core.rbac import require_perms
|
||||
from apps.access.models import AccessRequest
|
||||
from apps.keys.models import SSHKey
|
||||
from apps.servers.models import AgentCertificateAuthority, EnrollmentToken, Server, hostname_validator
|
||||
from apps.servers.models import (
|
||||
AgentCertificateAuthority,
|
||||
EnrollmentToken,
|
||||
Server,
|
||||
ServerAccount,
|
||||
hostname_validator,
|
||||
)
|
||||
from apps.telemetry.models import TelemetryEvent
|
||||
|
||||
|
||||
@@ -30,11 +37,30 @@ class AuthorizedKeyOut(Schema):
|
||||
fingerprint: str
|
||||
|
||||
|
||||
class AccountKeyOut(Schema):
|
||||
public_key: str
|
||||
fingerprint: str
|
||||
|
||||
|
||||
class AccountAccessOut(Schema):
|
||||
user_id: int
|
||||
username: str
|
||||
email: str
|
||||
keys: List[AccountKeyOut]
|
||||
|
||||
|
||||
class AccountSyncIn(Schema):
|
||||
user_id: int
|
||||
system_username: str
|
||||
present: bool
|
||||
|
||||
|
||||
class SyncReportIn(Schema):
|
||||
applied_count: int = Field(default=0, ge=0)
|
||||
revoked_count: int = Field(default=0, ge=0)
|
||||
message: Optional[str] = None
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
accounts: List[AccountSyncIn] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SyncReportOut(Schema):
|
||||
@@ -152,42 +178,55 @@ def build_router() -> Router:
|
||||
"""Resolve the effective authorized_keys list for a server.
|
||||
|
||||
Auth: required (admin/operator via API).
|
||||
Permissions: requires view access to servers, keys, and access requests.
|
||||
Behavior: combines approved access requests with active SSH keys to
|
||||
produce the exact key list the agent should deploy to the server.
|
||||
Permissions: requires view access to servers and keys.
|
||||
Behavior: uses server object permissions + 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.
|
||||
"""
|
||||
require_perms(
|
||||
request,
|
||||
"servers.view_server",
|
||||
"keys.view_sshkey",
|
||||
"access.view_accessrequest",
|
||||
)
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
except Server.DoesNotExist:
|
||||
raise HttpError(404, "Server not found")
|
||||
now = timezone.now()
|
||||
access_qs = AccessRequest.objects.select_related("requester").filter(
|
||||
server=server,
|
||||
status=AccessRequest.Status.APPROVED,
|
||||
)
|
||||
access_qs = access_qs.filter(models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now))
|
||||
users = [req.requester for req in access_qs if req.requester and req.requester.is_active]
|
||||
keys = SSHKey.objects.select_related("user").filter(
|
||||
user__in=users,
|
||||
is_active=True,
|
||||
revoked_at__isnull=True,
|
||||
)
|
||||
server = _get_server_or_404(server_id)
|
||||
users = _resolve_access_users(server)
|
||||
key_map = _key_map_for_users(users)
|
||||
output: list[AuthorizedKeyOut] = []
|
||||
for user in users:
|
||||
for key in key_map.get(user.id, []):
|
||||
output.append(
|
||||
AuthorizedKeyOut(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
email=user.email or "",
|
||||
public_key=key.public_key,
|
||||
fingerprint=key.fingerprint,
|
||||
)
|
||||
)
|
||||
return output
|
||||
|
||||
@router.get("/servers/{server_id}/accounts", response=List[AccountAccessOut], auth=None)
|
||||
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.
|
||||
Rationale: drives agent-side account provisioning.
|
||||
"""
|
||||
server = _get_server_or_404(server_id)
|
||||
users = _resolve_access_users(server)
|
||||
key_map = _key_map_for_users(users)
|
||||
return [
|
||||
AuthorizedKeyOut(
|
||||
user_id=key.user_id,
|
||||
username=key.user.username,
|
||||
email=key.user.email or "",
|
||||
public_key=key.public_key,
|
||||
fingerprint=key.fingerprint,
|
||||
AccountAccessOut(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
email=user.email or "",
|
||||
keys=[
|
||||
AccountKeyOut(public_key=key.public_key, fingerprint=key.fingerprint)
|
||||
for key in key_map.get(user.id, [])
|
||||
],
|
||||
)
|
||||
for key in keys
|
||||
for user in users
|
||||
]
|
||||
|
||||
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut, auth=None)
|
||||
@@ -216,6 +255,8 @@ def build_router() -> Router:
|
||||
**(payload.metadata or {}),
|
||||
},
|
||||
)
|
||||
if payload.accounts:
|
||||
_update_server_accounts(server, payload.accounts)
|
||||
return SyncReportOut(status="ok")
|
||||
|
||||
@router.post("/servers/{server_id}/logs", response=LogIngestOut, auth=None)
|
||||
@@ -277,6 +318,62 @@ def build_router() -> Router:
|
||||
return router
|
||||
|
||||
|
||||
def _get_server_or_404(server_id: int) -> Server:
|
||||
try:
|
||||
return Server.objects.get(id=server_id)
|
||||
except Server.DoesNotExist:
|
||||
raise HttpError(404, "Server not found")
|
||||
|
||||
|
||||
def _resolve_access_users(server: Server) -> list:
|
||||
users = list(
|
||||
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))
|
||||
|
||||
|
||||
def _key_map_for_users(users: list) -> dict[int, list[SSHKey]]:
|
||||
if not users:
|
||||
return {}
|
||||
keys = SSHKey.objects.select_related("user").filter(
|
||||
user__in=users,
|
||||
is_active=True,
|
||||
revoked_at__isnull=True,
|
||||
)
|
||||
key_map: dict[int, list[SSHKey]] = {}
|
||||
for key in keys:
|
||||
key_map.setdefault(key.user_id, []).append(key)
|
||||
return key_map
|
||||
|
||||
|
||||
def _update_server_accounts(server: Server, accounts: list[AccountSyncIn]) -> None:
|
||||
user_ids = {account.user_id for account in accounts}
|
||||
if not user_ids:
|
||||
return
|
||||
User = get_user_model()
|
||||
users = {user.id: user for user in User.objects.filter(id__in=user_ids)}
|
||||
now = timezone.now()
|
||||
for account in accounts:
|
||||
user = users.get(account.user_id)
|
||||
if not user:
|
||||
continue
|
||||
ServerAccount.objects.update_or_create(
|
||||
server=server,
|
||||
user=user,
|
||||
defaults={
|
||||
"system_username": account.system_username,
|
||||
"is_present": account.present,
|
||||
"last_synced_at": now,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _load_agent_ca() -> tuple[x509.Certificate, object, str]:
|
||||
ca = (
|
||||
AgentCertificateAuthority.objects.filter(is_active=True, revoked_at__isnull=True)
|
||||
|
||||
@@ -5,8 +5,8 @@ from typing import List, Optional
|
||||
from django.http import HttpRequest
|
||||
from ninja import Router, Schema
|
||||
from ninja.errors import HttpError
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from apps.core.rbac import require_perms
|
||||
from guardian.shortcuts import get_objects_for_user, get_perms
|
||||
from apps.core.rbac import require_authenticated, require_perms
|
||||
from apps.servers.models import Server
|
||||
|
||||
|
||||
@@ -32,20 +32,17 @@ def build_router() -> Router:
|
||||
"""List servers the caller can view.
|
||||
|
||||
Auth: required.
|
||||
Permissions: requires `servers.view_server` globally or per-object.
|
||||
Permissions: requires `servers.view_server` via object permissions.
|
||||
Behavior: returns only servers the user can see via object perms.
|
||||
Rationale: drives the server dashboard and access-aware navigation.
|
||||
"""
|
||||
require_perms(request, "servers.view_server")
|
||||
if request.user.has_perm("servers.view_server"):
|
||||
servers = Server.objects.all()
|
||||
else:
|
||||
servers = get_objects_for_user(
|
||||
request.user,
|
||||
"servers.view_server",
|
||||
klass=Server,
|
||||
accept_global_perms=False,
|
||||
)
|
||||
require_authenticated(request)
|
||||
servers = get_objects_for_user(
|
||||
request.user,
|
||||
"servers.view_server",
|
||||
klass=Server,
|
||||
accept_global_perms=False,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
@@ -64,18 +61,16 @@ def build_router() -> Router:
|
||||
"""Get a server record by id.
|
||||
|
||||
Auth: required.
|
||||
Permissions: requires `servers.view_server` globally or per-object.
|
||||
Permissions: requires `servers.view_server` via object permissions.
|
||||
Rationale: used by server detail views and API clients inspecting
|
||||
server metadata (hostname/IPs populated by the agent).
|
||||
"""
|
||||
require_perms(request, "servers.view_server")
|
||||
require_authenticated(request)
|
||||
try:
|
||||
server = Server.objects.get(id=server_id)
|
||||
except Server.DoesNotExist:
|
||||
raise HttpError(404, "Not Found")
|
||||
if not request.user.has_perm("servers.view_server", server) and not request.user.has_perm(
|
||||
"servers.view_server"
|
||||
):
|
||||
if "view_server" not in get_perms(request.user, server):
|
||||
raise HttpError(403, "Forbidden")
|
||||
return {
|
||||
"id": server.id,
|
||||
|
||||
Reference in New Issue
Block a user