Check in admin page in working state
This commit is contained in:
@ -13,12 +13,22 @@ dependencies = [
|
||||
"cryptography>=44.0.2",
|
||||
"fastapi[standard]>=0.115.12",
|
||||
"httpx>=0.28.1",
|
||||
"jinja2>=3.1.6",
|
||||
"jinja2-fragments>=1.9.0",
|
||||
"pydantic>=2.10.6",
|
||||
"pyjwt>=2.10.1",
|
||||
"pykeepass>=4.1.1.post1",
|
||||
"sqlmodel>=0.0.24",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
sshecret-admin = "sshecret_admin.cli:cli"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytailwindcss>=0.2.0",
|
||||
]
|
||||
|
||||
@ -1,5 +1 @@
|
||||
"""Sshecret Admin API."""
|
||||
|
||||
from .app import app
|
||||
|
||||
__all__ = ["app"]
|
||||
|
||||
@ -1,36 +1,40 @@
|
||||
"""Admin API."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Any
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
|
||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from sqlmodel import Session, select
|
||||
from sshecret.backend import Client, SshecretBackend
|
||||
from sshecret.backend.models import Secret
|
||||
|
||||
from . import keepass
|
||||
from .auth_models import User, PasswordDB, get_engine
|
||||
from .backend import BackendClient, Client
|
||||
from .master_password import setup_master_password
|
||||
from .settings import ServerSettings
|
||||
from .admin_backend import AdminBackend
|
||||
from .auth_models import (
|
||||
PasswordDB,
|
||||
Token,
|
||||
TokenData,
|
||||
User,
|
||||
create_access_token,
|
||||
verify_password,
|
||||
)
|
||||
from .settings import AdminServerSettings
|
||||
from .types import DBSessionDep
|
||||
from .view_models import (
|
||||
ClientCreate,
|
||||
SecretListView,
|
||||
SecretCreate,
|
||||
SecretUpdate,
|
||||
SecretView,
|
||||
UpdateKeyModel,
|
||||
UpdateKeyResponse,
|
||||
UpdatePoliciesRequest,
|
||||
)
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -39,64 +43,6 @@ JWT_ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
"""Token data."""
|
||||
|
||||
username: str | None = None
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
engine = get_engine(settings.admin_db)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Create database."""
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
|
||||
async def get_session():
|
||||
"""Get the session."""
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
def setup_password_manager() -> None:
|
||||
"""Setup password manager."""
|
||||
encr_master_password = setup_master_password(regenerate=False)
|
||||
if not encr_master_password:
|
||||
return
|
||||
|
||||
with Session(engine) as session:
|
||||
pwdb = PasswordDB(id=1, encrypted_password=encr_master_password)
|
||||
session.add(pwdb)
|
||||
session.commit()
|
||||
|
||||
|
||||
async def get_password_manager(session: Annotated[Session, Depends(get_session)]):
|
||||
"""Get password manager."""
|
||||
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
||||
if not password_db:
|
||||
raise HTTPException(
|
||||
500, detail="Error: The password manager has not yet been set up."
|
||||
)
|
||||
with keepass.load_password_manager(password_db.encrypted_password) as kp:
|
||||
yield kp
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify password against stored hash."""
|
||||
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
||||
|
||||
|
||||
def authenticate_user(session: Session, username: str, password: str) -> User | None:
|
||||
"""Authenticate user."""
|
||||
user = session.exec(select(User).where(User.username == username)).first()
|
||||
@ -107,9 +53,37 @@ def authenticate_user(session: Session, username: str, password: str) -> User |
|
||||
return user
|
||||
|
||||
|
||||
async def map_secrets_to_clients(
|
||||
backend: SshecretBackend,
|
||||
) -> defaultdict[str, list[str]]:
|
||||
"""Map secrets to clients."""
|
||||
clients = await backend.get_clients()
|
||||
client_secret_map: defaultdict[str, list[str]] = defaultdict(list)
|
||||
for client in clients:
|
||||
for secret in client.secrets:
|
||||
client_secret_map[secret].append(client.name)
|
||||
return client_secret_map
|
||||
|
||||
|
||||
def get_admin_api(
|
||||
get_db_session: DBSessionDep, settings: AdminServerSettings
|
||||
) -> APIRouter:
|
||||
"""Get Admin API."""
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
async def get_admin_backend(session: Annotated[Session, Depends(get_db_session)]):
|
||||
"""Get admin backend API."""
|
||||
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
||||
if not password_db:
|
||||
raise HTTPException(
|
||||
500, detail="Error: The password manager has not yet been set up."
|
||||
)
|
||||
admin = AdminBackend(settings, password_db.encrypted_password)
|
||||
yield admin
|
||||
|
||||
async def get_current_user(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
session: Annotated[Session, Depends(get_db_session)],
|
||||
) -> User:
|
||||
"""Get current user from token."""
|
||||
credentials_exception = HTTPException(
|
||||
@ -133,62 +107,21 @@ async def get_current_user(
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: Annotated[User, Depends(get_current_user)]
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
"""Get current active user."""
|
||||
if current_user.disabled:
|
||||
raise HTTPException(status_code=400, detail="Inactive or disabled user")
|
||||
return current_user
|
||||
|
||||
|
||||
def create_access_token(
|
||||
data: dict[str, Any], expires_delta: timedelta | None = None
|
||||
) -> str:
|
||||
"""Create access token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=JWT_ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
async def get_backend():
|
||||
"""Get backend client."""
|
||||
backend_client = BackendClient(settings)
|
||||
yield backend_client
|
||||
|
||||
|
||||
async def map_secrets_to_clients(backend: BackendClient) -> defaultdict[str, list[str]]:
|
||||
"""Map secrets to clients."""
|
||||
clients = await backend.get_clients()
|
||||
client_secret_map: defaultdict[str, list[str]] = defaultdict(list)
|
||||
for client in clients:
|
||||
for secret in client.secrets:
|
||||
client_secret_map[secret].append(client.name)
|
||||
return client_secret_map
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
"""Create lifespan context for the app."""
|
||||
init_db()
|
||||
setup_password_manager()
|
||||
yield
|
||||
|
||||
|
||||
api = APIRouter(
|
||||
prefix=f"/api/{API_VERSION}",
|
||||
lifespan=lifespan,
|
||||
app = APIRouter(
|
||||
prefix=f"/api/{API_VERSION}", dependencies=[Depends(get_current_active_user)]
|
||||
)
|
||||
|
||||
|
||||
@api.post("/token")
|
||||
@app.post("/token")
|
||||
async def login_for_access_token(
|
||||
session: Annotated[Session, Depends(get_session)],
|
||||
session: Annotated[Session, Depends(get_db_session)],
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
) -> Token:
|
||||
"""Login user and generate token."""
|
||||
@ -201,184 +134,49 @@ async def login_for_access_token(
|
||||
)
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username}, expires_delta=access_token_expires
|
||||
settings,
|
||||
data={"sub": user.username},
|
||||
expires_delta=access_token_expires,
|
||||
)
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
|
||||
|
||||
@api.get("/secrets/")
|
||||
async def get_secret_names(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||
password_manager: Annotated[keepass.PasswordContext, Depends(get_password_manager)],
|
||||
) -> list[SecretListView]:
|
||||
"""Get Secret Names."""
|
||||
# We get the list of clients first, so we can resolve access.
|
||||
LOG.info("User %s requested get_secret_names", current_user.username)
|
||||
client_secret_map = await map_secrets_to_clients(backend)
|
||||
secrets = password_manager.get_available_secrets()
|
||||
results: list[SecretListView] = []
|
||||
for secret in secrets:
|
||||
client_list = client_secret_map.get(secret, [])
|
||||
results.append(SecretListView(name=secret, clients=client_list))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@api.get("/secrets/{name}")
|
||||
async def get_secret(
|
||||
name: str,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||
password_manager: Annotated[keepass.PasswordContext, Depends(get_password_manager)],
|
||||
) -> SecretView:
|
||||
"""Get a secret."""
|
||||
LOG.info("User %s viewed secret %s", current_user.username, name)
|
||||
client_secret_map = await map_secrets_to_clients(backend)
|
||||
secret = password_manager.get_secret(name)
|
||||
if not secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found."
|
||||
)
|
||||
clients = client_secret_map[secret]
|
||||
return SecretView(name=name, secret=secret, clients=clients)
|
||||
|
||||
|
||||
@api.get("/clients/")
|
||||
@app.get("/clients/")
|
||||
async def get_clients(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
||||
) -> list[Client]:
|
||||
"""Get clients."""
|
||||
LOG.info("User %s requested get_clients", current_user.username)
|
||||
clients = await backend.get_clients()
|
||||
clients = await admin.get_clients()
|
||||
return clients
|
||||
|
||||
|
||||
@api.post("/clients/")
|
||||
@app.post("/clients/")
|
||||
async def create_client(
|
||||
new_client: ClientCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
) -> Client:
|
||||
"""Create a new client."""
|
||||
LOG.info(
|
||||
"User %s requested create_client %", current_user.username, new_client.name
|
||||
)
|
||||
await backend.register_client(new_client.name, new_client.public_key)
|
||||
sources: list[str] | None = None
|
||||
if new_client.sources:
|
||||
LOG.debug("Creating policy sources")
|
||||
sources = [str(source) for source in new_client.sources]
|
||||
await backend.update_client_sources(new_client.name, sources)
|
||||
|
||||
client = await backend.get_client(new_client.name)
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="The client could not be created",
|
||||
client = await admin.create_client(
|
||||
new_client.name, new_client.public_key, sources
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
@api.delete("/clients/{name}")
|
||||
@app.delete("/clients/{name}")
|
||||
async def delete_client(
|
||||
name: str,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||
name: str, admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
||||
) -> None:
|
||||
"""Delete a client."""
|
||||
LOG.info("User %s requested delete_client %s", current_user.username, name)
|
||||
await backend.delete_client(name)
|
||||
await admin.delete_client(name)
|
||||
|
||||
|
||||
@api.put("/clients/{name}/public-key")
|
||||
async def update_client_public_key(
|
||||
name: str,
|
||||
updated: UpdateKeyModel,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||
password_manager: Annotated[keepass.PasswordContext, Depends(get_password_manager)],
|
||||
) -> UpdateKeyResponse:
|
||||
"""Update client public key.
|
||||
|
||||
Updating the public key will invalidate the current secrets, so these well
|
||||
be resolved first, and re-encrypted using the new key.
|
||||
"""
|
||||
LOG.info(
|
||||
"User %s requested public key update for client %s", current_user.username, name
|
||||
)
|
||||
# Let's first ensure that the key is actually updated.
|
||||
client = await backend.get_client(name)
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found."
|
||||
)
|
||||
|
||||
if client.public_key == updated.public_key:
|
||||
return UpdateKeyResponse(
|
||||
public_key=updated.public_key,
|
||||
detail="Updated key identical to existing key. No changes were made.",
|
||||
)
|
||||
LOG.debug("Updating public key on backend.")
|
||||
await backend.update_client_key(name, updated.public_key)
|
||||
client.public_key = updated.public_key
|
||||
response = UpdateKeyResponse(public_key=updated.public_key)
|
||||
for secret in client.secrets:
|
||||
LOG.debug("Re-encrypting secret %s for client %s", secret, name)
|
||||
secret_value = password_manager.get_secret(secret)
|
||||
if not secret_value:
|
||||
LOG.warning("Referenced secret %s does not exist! Skipping.", secret_value)
|
||||
continue
|
||||
encrypted = client.encrypt(secret_value)
|
||||
LOG.debug("Sending new encrypted value to backend.")
|
||||
await backend.create_secret(name, secret, encrypted)
|
||||
response.updated_secrets.append(secret)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@api.put("/clients/{name}/secrets/{secret_name}")
|
||||
async def add_secret_to_client(
|
||||
name: str,
|
||||
secret_name: str,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||
password_manager: Annotated[keepass.PasswordContext, Depends(get_password_manager)],
|
||||
) -> None:
|
||||
"""Add secret to a client."""
|
||||
LOG.info(
|
||||
"User %s requested add_secret_to_client for secret %s",
|
||||
current_user.username,
|
||||
secret_name,
|
||||
)
|
||||
client = await backend.get_client(name)
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||
)
|
||||
secret = password_manager.get_secret(secret_name)
|
||||
if not secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||
)
|
||||
await backend.create_secret(name, secret_name, client.encrypt(secret))
|
||||
|
||||
|
||||
@api.delete("/clients/{name}/secrets/{secret_name}")
|
||||
@app.delete("/clients/{name}/secrets/{secret_name}")
|
||||
async def delete_secret_from_client(
|
||||
name: str,
|
||||
secret_name: str,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
) -> None:
|
||||
"""Delete a secret from a client."""
|
||||
LOG.info(
|
||||
"User % requested delete of secret. Client: %s, secret: %s",
|
||||
current_user.username,
|
||||
name,
|
||||
secret_name,
|
||||
)
|
||||
client = await backend.get_client(name)
|
||||
client = await admin.get_client(name)
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||
@ -388,19 +186,16 @@ async def delete_secret_from_client(
|
||||
LOG.debug("Client does not have requested secret. No action to perform.")
|
||||
return None
|
||||
|
||||
await backend.delete_client_secret(name, secret_name)
|
||||
await admin.delete_client_secret(name, secret_name)
|
||||
|
||||
|
||||
@api.put("/clients/{name}/policies")
|
||||
@app.put("/clients/{name}/policies")
|
||||
async def update_client_policies(
|
||||
name: str,
|
||||
updated: UpdatePoliciesRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
backend: Annotated[BackendClient, Depends(get_backend)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
) -> Client:
|
||||
"""Update the client access policies."""
|
||||
LOG.info("User %s requested update_client_policies.", current_user.username)
|
||||
client = await backend.get_client(name)
|
||||
client = await admin.get_client(name)
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||
@ -409,9 +204,81 @@ async def update_client_policies(
|
||||
LOG.debug("Old policies: %r. New: %r", client.policies, updated.sources)
|
||||
|
||||
addresses: list[str] = [str(source) for source in updated.sources]
|
||||
await backend.update_client_sources(name, addresses)
|
||||
client = await backend.get_client(name)
|
||||
await admin.update_client_sources(name, addresses)
|
||||
client = await admin.get_client(name)
|
||||
|
||||
assert client is not None, "Critical: The client disappeared after update!"
|
||||
|
||||
return client
|
||||
|
||||
@app.get("/secrets/")
|
||||
async def get_secret_names(
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
||||
) -> list[Secret]:
|
||||
"""Get Secret Names."""
|
||||
return await admin.get_secrets()
|
||||
|
||||
@app.post("/secrets/")
|
||||
async def add_secret(
|
||||
secret: SecretCreate, admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
||||
) -> None:
|
||||
"""Create a secret."""
|
||||
await admin.add_secret(secret.name, secret.get_secret(), secret.clients)
|
||||
|
||||
@app.get("/secrets/{name}")
|
||||
async def get_secret(
|
||||
name: str, admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
||||
) -> SecretView:
|
||||
"""Get a secret."""
|
||||
secret_view = await admin.get_secret(name)
|
||||
|
||||
if not secret_view:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found."
|
||||
)
|
||||
return secret_view
|
||||
|
||||
@app.put("/secrets/{name}")
|
||||
async def update_secret(
|
||||
name: str,
|
||||
value: SecretUpdate,
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
) -> None:
|
||||
new_value = value.get_secret()
|
||||
await admin.update_secret(name, new_value)
|
||||
|
||||
@app.delete("/secrets/{name}")
|
||||
async def delete_secret(
|
||||
name: str,
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
) -> None:
|
||||
"""Delete secret."""
|
||||
await admin.delete_secret(name)
|
||||
|
||||
@app.put("/clients/{name}/public-key")
|
||||
async def update_client_public_key(
|
||||
name: str,
|
||||
updated: UpdateKeyModel,
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
) -> UpdateKeyResponse:
|
||||
"""Update client public key.
|
||||
|
||||
Updating the public key will invalidate the current secrets, so these well
|
||||
be resolved first, and re-encrypted using the new key.
|
||||
"""
|
||||
# Let's first ensure that the key is actually updated.
|
||||
updated_secrets = await admin.update_client_public_key(name, updated.public_key)
|
||||
return UpdateKeyResponse(
|
||||
public_key=updated.public_key, updated_secrets=updated_secrets
|
||||
)
|
||||
|
||||
@app.put("/clients/{name}/secrets/{secret_name}")
|
||||
async def add_secret_to_client(
|
||||
name: str,
|
||||
secret_name: str,
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
) -> None:
|
||||
"""Add secret to a client."""
|
||||
await admin.create_client_secret(name, secret_name)
|
||||
|
||||
return app
|
||||
|
||||
402
packages/sshecret-admin/src/sshecret_admin/admin_backend.py
Normal file
402
packages/sshecret-admin/src/sshecret_admin/admin_backend.py
Normal file
@ -0,0 +1,402 @@
|
||||
"""API for working with the clients.
|
||||
|
||||
Since we have a frontend and a REST API, it makes sense to have a generic library to work with the clients.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sshecret.backend import AuditLog, Client, ClientFilter, Secret, SshecretBackend
|
||||
from sshecret.backend.models import DetailedSecrets
|
||||
from sshecret.crypto import encrypt_string, load_public_key
|
||||
|
||||
from .keepass import PasswordContext, load_password_manager
|
||||
from .settings import AdminServerSettings
|
||||
from .view_models import SecretView
|
||||
|
||||
|
||||
class ClientManagementError(Exception):
|
||||
"""Base exception for client management operations."""
|
||||
|
||||
|
||||
class ClientNotFoundError(ClientManagementError):
|
||||
"""Client not found."""
|
||||
|
||||
|
||||
class SecretNotFoundError(ClientManagementError):
|
||||
"""Secret not found."""
|
||||
|
||||
|
||||
class BackendUnavailableError(ClientManagementError):
|
||||
"""Backend unavailable."""
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminBackend:
|
||||
"""Admin backend API."""
|
||||
|
||||
def __init__(self, settings: AdminServerSettings, keepass_password: str) -> None:
|
||||
"""Create client management API."""
|
||||
self.settings: AdminServerSettings = settings
|
||||
self.backend: SshecretBackend = SshecretBackend(
|
||||
str(settings.backend_url), settings.backend_token
|
||||
)
|
||||
self.keepass_password: str = keepass_password
|
||||
|
||||
@contextmanager
|
||||
def password_manager(self) -> Iterator[PasswordContext]:
|
||||
"""Open the password manager."""
|
||||
with load_password_manager(self.settings, self.keepass_password) as kp:
|
||||
yield kp
|
||||
|
||||
async def _get_clients(self, filter: ClientFilter | None = None) -> list[Client]:
|
||||
"""Get clients from backend."""
|
||||
return await self.backend.get_clients(filter)
|
||||
|
||||
async def get_clients(self, filter: ClientFilter | None = None) -> list[Client]:
|
||||
"""Get clients from backend."""
|
||||
try:
|
||||
return await self._get_clients(filter)
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def _get_client(self, name: str) -> Client | None:
|
||||
"""Get a client from the backend."""
|
||||
return await self.backend.get_client(name)
|
||||
|
||||
async def _verify_client_exists(self, name: str) -> None:
|
||||
"""Check that a client exists."""
|
||||
client = await self.backend.get_client(name)
|
||||
if not client:
|
||||
raise ClientNotFoundError()
|
||||
return None
|
||||
|
||||
async def verify_client_exists(self, name: str) -> None:
|
||||
"""Check that a client exists."""
|
||||
try:
|
||||
await self._verify_client_exists(name)
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def get_client(self, name: str) -> Client | None:
|
||||
"""Get a client from the backend."""
|
||||
try:
|
||||
return await self._get_client(name)
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def _create_client(
|
||||
self,
|
||||
name: str,
|
||||
public_key: str,
|
||||
description: str | None = None,
|
||||
sources: list[str] | None = None,
|
||||
) -> Client:
|
||||
"""Create client."""
|
||||
await self.backend.create_client(name, public_key, description)
|
||||
if sources:
|
||||
await self.backend.update_client_sources(name, sources)
|
||||
client = await self.get_client(name)
|
||||
|
||||
if not client:
|
||||
raise ClientNotFoundError()
|
||||
|
||||
return client
|
||||
|
||||
async def create_client(
|
||||
self,
|
||||
name: str,
|
||||
public_key: str,
|
||||
description: str | None = None,
|
||||
sources: list[str] | None = None,
|
||||
) -> Client:
|
||||
"""Create client."""
|
||||
try:
|
||||
return await self._create_client(name, public_key, description, sources)
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def _update_client_public_key(
|
||||
self, name: str, new_key: str, password_manager: PasswordContext
|
||||
) -> list[str]:
|
||||
"""Update client public key."""
|
||||
LOG.info(
|
||||
"Updating client %s public key. This will invalidate all existing secrets."
|
||||
)
|
||||
client = await self.get_client(name)
|
||||
if not client:
|
||||
raise ClientNotFoundError()
|
||||
await self.backend.update_client_key(name, new_key)
|
||||
updated_secrets: list[str] = []
|
||||
for secret in client.secrets:
|
||||
LOG.debug("Re-encrypting secret %s for client %s", secret, name)
|
||||
secret_value = password_manager.get_secret(secret)
|
||||
if not secret_value:
|
||||
LOG.warning(
|
||||
"Referenced secret %s does not exist! Skipping.", secret_value
|
||||
)
|
||||
continue
|
||||
rsa_public_key = load_public_key(client.public_key.encode())
|
||||
encrypted = encrypt_string(secret_value, rsa_public_key)
|
||||
LOG.debug("Sending new encrypted value to backend.")
|
||||
await self.backend.create_client_secret(name, secret, encrypted)
|
||||
updated_secrets.append(secret)
|
||||
|
||||
return updated_secrets
|
||||
|
||||
async def update_client_public_key(self, name: str, new_key: str) -> list[str]:
|
||||
"""Update client public key."""
|
||||
try:
|
||||
with self.password_manager() as password_manager:
|
||||
return await self._update_client_public_key(
|
||||
name, new_key, password_manager
|
||||
)
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def _update_client(self, new_client: Client) -> Client:
|
||||
"""Update a client object."""
|
||||
existing_client = await self.get_client(new_client.name)
|
||||
if not existing_client:
|
||||
raise ClientNotFoundError()
|
||||
await self.backend.update_client(new_client)
|
||||
if new_client.public_key != existing_client.public_key:
|
||||
await self.update_client_public_key(new_client.name, new_client.public_key)
|
||||
|
||||
updated_client = await self.get_client(new_client.name)
|
||||
if not updated_client:
|
||||
raise ClientNotFoundError()
|
||||
return updated_client
|
||||
|
||||
async def update_client(self, new_client: Client) -> Client:
|
||||
"""Update a client object."""
|
||||
try:
|
||||
return await self._update_client(new_client)
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def update_client_sources(self, name: str, sources: list[str]) -> None:
|
||||
"""Update client sources."""
|
||||
try:
|
||||
await self.backend.update_client_sources(name, sources)
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def _delete_client(self, name: str) -> None:
|
||||
"""Delete client."""
|
||||
await self.backend.delete_client(name)
|
||||
|
||||
async def delete_client(self, name: str) -> None:
|
||||
"""Delete client."""
|
||||
try:
|
||||
await self._delete_client(name)
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def delete_client_secret(self, client_name: str, secret_name: str) -> None:
|
||||
"""Delete a secret from a client."""
|
||||
try:
|
||||
await self.backend.delete_client_secret(client_name, secret_name)
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def _get_secrets(self) -> list[Secret]:
|
||||
"""Get secrets.
|
||||
|
||||
This fetches the secret to client mapping from backend, and adds secrets from the password manager.
|
||||
"""
|
||||
with self.password_manager() as password_manager:
|
||||
all_secrets = password_manager.get_available_secrets()
|
||||
|
||||
secrets = await self.backend.get_secrets()
|
||||
backend_secret_names = [secret.name for secret in secrets]
|
||||
for secret in all_secrets:
|
||||
if secret not in backend_secret_names:
|
||||
secrets.append(Secret(name=secret, clients=[]))
|
||||
|
||||
return secrets
|
||||
|
||||
async def get_secrets(self) -> list[Secret]:
|
||||
"""Get secrets from backend."""
|
||||
try:
|
||||
return await self._get_secrets()
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def _get_detailed_secrets(self) -> list[DetailedSecrets]:
|
||||
"""Get detailed secrets.
|
||||
|
||||
This fetches the secret to client mapping from backend, and adds secrets from the password manager.
|
||||
"""
|
||||
with self.password_manager() as password_manager:
|
||||
all_secrets = password_manager.get_available_secrets()
|
||||
|
||||
secrets = await self.backend.get_detailed_secrets()
|
||||
backend_secret_names = [secret.name for secret in secrets]
|
||||
for secret in all_secrets:
|
||||
if secret not in backend_secret_names:
|
||||
secrets.append(DetailedSecrets(name=secret, ids=[], clients=[]))
|
||||
|
||||
return secrets
|
||||
|
||||
async def get_detailed_secrets(self) -> list[DetailedSecrets]:
|
||||
"""Get detailed secrets from backend."""
|
||||
try:
|
||||
return await self._get_detailed_secrets()
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def get_secret(self, name: str) -> SecretView | None:
|
||||
"""Get secrets from backend."""
|
||||
try:
|
||||
return await self._get_secret(name)
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def _get_secret(self, name: str) -> SecretView | None:
|
||||
"""Get a secret, including the actual unencrypted value and clients."""
|
||||
with self.password_manager() as password_manager:
|
||||
secret = password_manager.get_secret(name)
|
||||
|
||||
if not secret:
|
||||
return None
|
||||
secret_view = SecretView(name=name, secret=secret)
|
||||
secret_mapping = await self.backend.get_secret(name)
|
||||
if secret_mapping:
|
||||
secret_view.clients = secret_mapping.clients
|
||||
|
||||
return secret_view
|
||||
|
||||
async def delete_secret(self, name: str) -> None:
|
||||
"""Delete a secret."""
|
||||
try:
|
||||
return await self._delete_secret(name)
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def _delete_secret(self, name: str) -> None:
|
||||
"""Delete a secret."""
|
||||
with self.password_manager() as password_manager:
|
||||
password_manager.delete_entry(name)
|
||||
|
||||
secret_mapping = await self.backend.get_secret(name)
|
||||
if not secret_mapping:
|
||||
return
|
||||
for client in secret_mapping.clients:
|
||||
LOG.info("Deleting secret %s from client %s", name, client)
|
||||
await self.backend.delete_client_secret(client, name)
|
||||
|
||||
async def _add_secret(
|
||||
self, name: str, value: str, clients: list[str] | None, update: bool = False
|
||||
) -> None:
|
||||
"""Add a secret."""
|
||||
with self.password_manager() as password_manager:
|
||||
password_manager.add_entry(name, value, update)
|
||||
|
||||
if update:
|
||||
secret_map = await self.backend.get_secret(name)
|
||||
if secret_map:
|
||||
clients = secret_map.clients
|
||||
|
||||
if not clients:
|
||||
return
|
||||
for client_name in clients:
|
||||
client = await self.get_client(client_name)
|
||||
if not client:
|
||||
if update:
|
||||
raise ClientNotFoundError()
|
||||
LOG.warning("Requested client %s not found!", client_name)
|
||||
continue
|
||||
public_key = load_public_key(client.public_key.encode())
|
||||
encrypted = encrypt_string(value, public_key)
|
||||
LOG.info("Wrote encrypted secret for client %s", client_name)
|
||||
await self.backend.create_client_secret(client_name, name, encrypted)
|
||||
|
||||
async def add_secret(
|
||||
self, name: str, value: str, clients: list[str] | None = None
|
||||
) -> None:
|
||||
"""Add a secret."""
|
||||
try:
|
||||
await self._add_secret(name, value, clients)
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def update_secret(self, name: str, value: str) -> None:
|
||||
"""Update secrets."""
|
||||
try:
|
||||
await self._add_secret(name, value, None, True)
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def _create_client_secret(self, client_name: str, secret_name: str) -> None:
|
||||
"""Create client secret."""
|
||||
client = await self.get_client(client_name)
|
||||
if not client:
|
||||
raise ClientNotFoundError()
|
||||
|
||||
with self.password_manager() as password_manager:
|
||||
secret = password_manager.get_secret(secret_name)
|
||||
if not secret:
|
||||
raise SecretNotFoundError()
|
||||
|
||||
public_key = load_public_key(client.public_key.encode())
|
||||
encrypted = encrypt_string(secret, public_key)
|
||||
await self.backend.create_client_secret(client_name, secret_name, encrypted)
|
||||
|
||||
async def create_client_secret(self, client_name: str, secret_name: str) -> None:
|
||||
"""Create client secret."""
|
||||
try:
|
||||
await self._create_client_secret(client_name, secret_name)
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def get_audit_log(
|
||||
self,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
client_name: str | None = None,
|
||||
subsystem: str | None = None,
|
||||
) -> list[AuditLog]:
|
||||
"""Get audit log from backend."""
|
||||
return await self.backend.get_audit_log(offset, limit, client_name, subsystem)
|
||||
|
||||
async def write_audit_log(self, entry: AuditLog) -> None:
|
||||
"""Write to the audit log."""
|
||||
if not entry.subsystem:
|
||||
entry.subsystem = "admin"
|
||||
await self.backend.add_audit_log(entry)
|
||||
|
||||
async def get_audit_log_count(self) -> int:
|
||||
"""Get audit log count."""
|
||||
return await self.backend.get_audit_log_count()
|
||||
@ -1,21 +1,118 @@
|
||||
"""FastAPI app."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
#
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .admin_api import api
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from .admin_api import get_admin_api
|
||||
from .auth_models import init_db, PasswordDB, AuthenticationFailedError, AuthenticationNeededError
|
||||
from .db import setup_database
|
||||
from .master_password import setup_master_password
|
||||
from .settings import AdminServerSettings
|
||||
from .frontend import create_frontend
|
||||
from .types import DBSessionDep
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
# dir_path = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
def setup_frontend(
|
||||
app: FastAPI, settings: AdminServerSettings, get_db_session: DBSessionDep
|
||||
) -> None:
|
||||
"""Setup frontend."""
|
||||
script_path = Path(os.path.dirname(os.path.realpath(__file__)))
|
||||
static_path = script_path / "static"
|
||||
|
||||
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
||||
frontend = create_frontend(settings, get_db_session)
|
||||
app.include_router(frontend)
|
||||
|
||||
|
||||
def create_admin_app(
|
||||
settings: AdminServerSettings, with_frontend: bool = True
|
||||
) -> FastAPI:
|
||||
"""Create admin app."""
|
||||
engine, get_db_session = setup_database(settings.admin_db)
|
||||
|
||||
def setup_password_manager() -> None:
|
||||
"""Setup password manager."""
|
||||
encr_master_password = setup_master_password(
|
||||
settings=settings, regenerate=False
|
||||
)
|
||||
with Session(engine) as session:
|
||||
existing_password = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
||||
|
||||
if not encr_master_password:
|
||||
if existing_password:
|
||||
LOG.info("Master password already defined.")
|
||||
return
|
||||
# Looks like we have to regenerate it
|
||||
LOG.warning("Master password was set, but not saved to the database. Regenerating it.")
|
||||
encr_master_password = setup_master_password(settings=settings, regenerate=True)
|
||||
|
||||
assert encr_master_password is not None
|
||||
|
||||
with Session(engine) as session:
|
||||
pwdb = PasswordDB(id=1, encrypted_password=encr_master_password)
|
||||
session.add(pwdb)
|
||||
session.commit()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
"""Create database before starting the server."""
|
||||
init_db(engine)
|
||||
setup_password_manager()
|
||||
yield
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
|
||||
)
|
||||
|
||||
app.include_router(api)
|
||||
@app.exception_handler(AuthenticationNeededError)
|
||||
async def authentication_needed_handler(
|
||||
request: Request, exc: AuthenticationNeededError,
|
||||
):
|
||||
qs = f"error_title={exc.login_error.title}&error_message={exc.login_error.message}"
|
||||
return RedirectResponse(f"/?{qs}")
|
||||
|
||||
@app.exception_handler(AuthenticationFailedError)
|
||||
async def authentication_failed_handler(
|
||||
request: Request, exc: AuthenticationNeededError,
|
||||
):
|
||||
qs = f"error_title={exc.login_error.title}&error_message={exc.login_error.message}"
|
||||
return RedirectResponse(f"/?{qs}")
|
||||
|
||||
@app.get("/health")
|
||||
async def get_health() -> JSONResponse:
|
||||
"""Provide simple health check."""
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK, content=jsonable_encoder({"status": "LIVE"})
|
||||
)
|
||||
|
||||
admin_api = get_admin_api(get_db_session, settings)
|
||||
|
||||
app.include_router(admin_api)
|
||||
if with_frontend:
|
||||
setup_frontend(app, settings, get_db_session)
|
||||
|
||||
return app
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
"""Models for authentication."""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import bcrypt
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.engine import URL
|
||||
from typing import Any, override
|
||||
import jwt
|
||||
from sqlmodel import SQLModel, Field
|
||||
from sshecret_admin.settings import AdminServerSettings
|
||||
|
||||
from sqlmodel import SQLModel, Field, create_engine
|
||||
|
||||
JWT_ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
@ -22,9 +27,9 @@ class User(SQLModel, table=True):
|
||||
)
|
||||
|
||||
|
||||
|
||||
class PasswordDB(SQLModel, table=True):
|
||||
"""Password database."""
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
encrypted_password: str
|
||||
|
||||
@ -42,9 +47,79 @@ class PasswordDB(SQLModel, table=True):
|
||||
)
|
||||
|
||||
|
||||
def get_engine(filename: Path, echo: bool = False) -> sa.Engine:
|
||||
"""Initialize the engine."""
|
||||
url = URL.create(drivername="sqlite", database=str(filename.absolute()))
|
||||
engine = create_engine(url, echo=echo)
|
||||
def init_db(engine: sa.Engine) -> None:
|
||||
"""Create database."""
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
return engine
|
||||
|
||||
class TokenData(SQLModel):
|
||||
"""Token data."""
|
||||
|
||||
username: str | None = None
|
||||
|
||||
|
||||
class Token(SQLModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
def create_access_token(
|
||||
settings: AdminServerSettings,
|
||||
data: dict[str, Any],
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""Create access token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=JWT_ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify password against stored hash."""
|
||||
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
||||
|
||||
|
||||
def check_password(plain_password: str, hashed_password: str) -> None:
|
||||
"""Check password.
|
||||
|
||||
If password doesn't match, throw AuthenticationFailedError.
|
||||
"""
|
||||
if not verify_password(plain_password, hashed_password):
|
||||
raise AuthenticationFailedError()
|
||||
|
||||
|
||||
class LoginError(SQLModel):
|
||||
"""Login Error model."""
|
||||
|
||||
title: str
|
||||
message: str
|
||||
|
||||
|
||||
class AuthenticationFailedError(Exception):
|
||||
"""Authentication failed."""
|
||||
|
||||
@override
|
||||
def __init__(self, message: str | None = None) -> None:
|
||||
"""Initialize exception class."""
|
||||
if not message:
|
||||
message = "Invalid user or password."
|
||||
super().__init__(message)
|
||||
self.login_error: LoginError = LoginError(
|
||||
title="Authentication Failed", message=message
|
||||
)
|
||||
|
||||
|
||||
class AuthenticationNeededError(Exception):
|
||||
"""Authentication needed error."""
|
||||
|
||||
@override
|
||||
def __init__(self, message: str | None = None) -> None:
|
||||
"""Initialize exception class."""
|
||||
if not message:
|
||||
message = "You need to be logged in to continue."
|
||||
super().__init__(message)
|
||||
self.login_error: LoginError = LoginError(title="Unauthorized", message=message)
|
||||
|
||||
@ -1,156 +0,0 @@
|
||||
"""Backend API.
|
||||
|
||||
TODO:
|
||||
- Handle exceptions with custom exceptions
|
||||
- Move to shared library.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import urllib.parse
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork, TypeAdapter
|
||||
|
||||
from .crypto import load_public_key, encrypt_string
|
||||
from .settings import ServerSettings
|
||||
|
||||
|
||||
class Client(BaseModel):
|
||||
"""Implementation of the backend class ClientView."""
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
public_key: str
|
||||
secrets: list[str]
|
||||
policies: list[IPvAnyNetwork | IPvAnyAddress]
|
||||
created_at: datetime
|
||||
updated_at: datetime | None
|
||||
|
||||
def encrypt(self, value: str) -> str:
|
||||
"""Encrypt a string."""
|
||||
public_key = load_public_key(self.public_key.encode())
|
||||
return encrypt_string(value, public_key=public_key)
|
||||
|
||||
|
||||
class BackendClient:
|
||||
"""Backend Client."""
|
||||
|
||||
def __init__(self, settings: ServerSettings | None = None) -> None:
|
||||
"""Initialize backend client."""
|
||||
if not settings:
|
||||
settings = ServerSettings() # pyright: ignore[reportCallIssue]
|
||||
self.settings: ServerSettings = settings
|
||||
|
||||
@property
|
||||
def headers(self) -> dict[str, str]:
|
||||
"""Get the headers."""
|
||||
return {"X-Api-Token": self.settings.backend_token}
|
||||
|
||||
def _format_url(self, path: str) -> str:
|
||||
"""Format a URL."""
|
||||
return urllib.parse.urljoin(str(self.settings.backend_url), path)
|
||||
|
||||
async def request(self, path: str) -> httpx.Response:
|
||||
"""Send a simple GET request."""
|
||||
url = self._format_url(path)
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(url, headers=self.headers)
|
||||
return response
|
||||
|
||||
async def register_client(self, username: str, public_key: str) -> None:
|
||||
"""Register a new client."""
|
||||
data = {
|
||||
"name": username,
|
||||
"public_key": public_key,
|
||||
}
|
||||
path = "api/v1/clients/"
|
||||
url = self._format_url(path)
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.post(url, headers=self.headers, json=data)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
async def update_client_key(self, client_name: str, public_key: str) -> None:
|
||||
"""Update the client key."""
|
||||
path = f"api/v1/clients/{client_name}/public-key"
|
||||
url = self._format_url(path)
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.post(
|
||||
url, headers=self.headers, json={"public_key": public_key}
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
async def create_secret(
|
||||
self, client_name: str, secret_name: str, encrypted_secret: str
|
||||
) -> None:
|
||||
"""Create a secret.
|
||||
|
||||
This will overwrite any existing secret with that name.
|
||||
"""
|
||||
path = f"api/v1/clients/{client_name}/secrets/{secret_name}"
|
||||
url = self._format_url(path)
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.put(
|
||||
url, headers=self.headers, json={"value": encrypted_secret}
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
async def delete_client_secret(self, client_name: str, secret_name: str) -> None:
|
||||
"""Delete a secret from a client."""
|
||||
path = f"api/v1/clients/{client_name}/secrets/{secret_name}"
|
||||
url = self._format_url(path)
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.delete(url, headers=self.headers)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
async def get_client(self, name: str) -> Client | None:
|
||||
"""Get a single client."""
|
||||
path = f"api/v1/clients/{name}"
|
||||
response = await self.request(path)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
client = Client.model_validate(response.json())
|
||||
return client
|
||||
|
||||
async def get_clients(self) -> list[Client]:
|
||||
"""Get all clients."""
|
||||
path = "api/v1/clients/"
|
||||
url = self._format_url(path)
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.get(url, headers=self.headers)
|
||||
|
||||
response.raise_for_status()
|
||||
client_list_adapter = TypeAdapter(list[Client])
|
||||
return client_list_adapter.validate_python(response.json())
|
||||
|
||||
async def update_client_sources(
|
||||
self, client_name: str, addresses: list[str] | None
|
||||
) -> None:
|
||||
"""Update client source addresses.
|
||||
|
||||
Pass None to sources to allow from all.
|
||||
"""
|
||||
if not addresses:
|
||||
addresses = []
|
||||
|
||||
path = f"api/v1/clients/{client_name}/policies/"
|
||||
url = self._format_url(path)
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.put(
|
||||
url, headers=self.headers, json={"sources": addresses}
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
async def delete_client(self, client_name: str) -> None:
|
||||
"""Delete a client."""
|
||||
path = f"api/v1/clients/{client_name}"
|
||||
url = self._format_url(path)
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.delete(url, headers=self.headers)
|
||||
|
||||
response.raise_for_status()
|
||||
143
packages/sshecret-admin/src/sshecret_admin/cli.py
Normal file
143
packages/sshecret-admin/src/sshecret_admin/cli.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""Sshecret admin CLI helper."""
|
||||
|
||||
import asyncio
|
||||
import code
|
||||
from collections.abc import Awaitable
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
import bcrypt
|
||||
import click
|
||||
from sshecret_admin.admin_backend import AdminBackend
|
||||
import uvicorn
|
||||
from pydantic import ValidationError
|
||||
from sqlmodel import Session, create_engine, select
|
||||
from .auth_models import init_db, User, PasswordDB
|
||||
from .settings import AdminServerSettings
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter("%(asctime)s [%(processName)s: %(process)d] [%(threadName)s: %(thread)d] [%(levelname)s] %(name)s: %(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
LOG = logging.getLogger()
|
||||
LOG.addHandler(handler)
|
||||
LOG.setLevel(logging.INFO)
|
||||
|
||||
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash password."""
|
||||
salt = bcrypt.gensalt()
|
||||
hashed_password = bcrypt.hashpw(password.encode(), salt)
|
||||
return hashed_password.decode()
|
||||
|
||||
def create_user(session: Session, username: str, password: str) -> None:
|
||||
"""Create a user."""
|
||||
hashed_password = hash_password(password)
|
||||
user = User(username=username, hashed_password=hashed_password)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option("--debug", is_flag=True)
|
||||
@click.pass_context
|
||||
def cli(ctx: click.Context, debug: bool) -> None:
|
||||
"""Sshecret Admin."""
|
||||
if debug:
|
||||
LOG.setLevel(logging.DEBUG)
|
||||
try:
|
||||
settings = AdminServerSettings() # pyright: ignore[reportCallIssue]
|
||||
except ValidationError as e:
|
||||
raise click.ClickException("Error: One or more required environment options are missing.") from e
|
||||
ctx.obj = settings
|
||||
|
||||
|
||||
@cli.command("adduser")
|
||||
@click.argument("username")
|
||||
@click.password_option()
|
||||
@click.pass_context
|
||||
def cli_create_user(ctx: click.Context, username: str, password: str) -> None:
|
||||
"""Create user."""
|
||||
settings = cast(AdminServerSettings, ctx.obj)
|
||||
engine = create_engine(settings.admin_db)
|
||||
init_db(engine)
|
||||
with Session(engine) as session:
|
||||
create_user(session, username, password)
|
||||
|
||||
click.echo("User created.")
|
||||
|
||||
@cli.command("passwd")
|
||||
@click.argument("username")
|
||||
@click.password_option()
|
||||
@click.pass_context
|
||||
def cli_change_user_passwd(ctx: click.Context, username: str, password: str) -> None:
|
||||
"""Change password on user."""
|
||||
settings = cast(AdminServerSettings, ctx.obj)
|
||||
engine = create_engine(settings.admin_db)
|
||||
init_db(engine)
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(User).where(User.username == username)).first()
|
||||
if not user:
|
||||
raise click.ClickException(f"Error: No such user, {username}.")
|
||||
new_passwd_hash = hash_password(password)
|
||||
user.hashed_password = new_passwd_hash
|
||||
session.add(user)
|
||||
session.commit()
|
||||
click.echo("Password updated.")
|
||||
|
||||
@cli.command("deluser")
|
||||
@click.argument("username")
|
||||
@click.confirmation_option()
|
||||
@click.pass_context
|
||||
def cli_delete_user(ctx: click.Context, username: str) -> None:
|
||||
"""Remove a user."""
|
||||
settings = cast(AdminServerSettings, ctx.obj)
|
||||
engine = create_engine(settings.admin_db)
|
||||
init_db(engine)
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(User).where(User.username == username)).first()
|
||||
if not user:
|
||||
raise click.ClickException(f"Error: No such user, {username}.")
|
||||
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
|
||||
click.echo("User deleted.")
|
||||
|
||||
|
||||
@cli.command("run")
|
||||
@click.option("--host", default="127.0.0.1")
|
||||
@click.option("--port", default=8822, type=click.INT)
|
||||
@click.option("--dev", is_flag=True)
|
||||
@click.option("--workers", type=click.INT)
|
||||
def cli_run(host: str, port: int, dev: bool, workers: int | None) -> None:
|
||||
"""Run the server."""
|
||||
uvicorn.run("sshecret_admin.main:app", host=host, port=port, reload=dev, workers=workers)
|
||||
|
||||
|
||||
@cli.command("repl")
|
||||
@click.pass_context
|
||||
def cli_repl(ctx: click.Context) -> None:
|
||||
"""Run an interactive console."""
|
||||
settings = cast(AdminServerSettings, ctx.obj)
|
||||
engine = create_engine(settings.admin_db)
|
||||
init_db(engine)
|
||||
with Session(engine) as session:
|
||||
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
||||
|
||||
if not password_db:
|
||||
raise click.ClickException("Error: Password database has not yet been setup. Start the server to finish setup.")
|
||||
|
||||
def run(func: Awaitable[Any]) -> Any:
|
||||
"""Run an async function."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.run_until_complete(func)
|
||||
|
||||
admin = AdminBackend(settings, password_db.encrypted_password)
|
||||
locals = {
|
||||
"run": run,
|
||||
"admin": admin,
|
||||
}
|
||||
banner = "Sshecret-admin REPL\nAdmin backend API bound to 'admin'. Run async functions with run()"
|
||||
console = code.InteractiveConsole(locals=locals, local_exit=True)
|
||||
console.interact(banner=banner, exitmsg="Bye!")
|
||||
@ -1,2 +0,0 @@
|
||||
RSA_PUBLIC_EXPONENT = 65537
|
||||
RSA_KEY_SIZE = 2048
|
||||
@ -1,125 +0,0 @@
|
||||
"""Encryption related functions.
|
||||
|
||||
Note! Encryption uses the less secure PKCS1v15 padding. This is to allow
|
||||
decryption via openssl on the command line.
|
||||
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
|
||||
from . import constants
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_public_key(keybytes: bytes) -> rsa.RSAPublicKey:
|
||||
public_key = serialization.load_ssh_public_key(keybytes)
|
||||
if not isinstance(public_key, rsa.RSAPublicKey):
|
||||
raise RuntimeError("Only RSA keys are supported.")
|
||||
return public_key
|
||||
|
||||
|
||||
def validate_public_key(key: str) -> bool:
|
||||
"""Check if key provided in a string is valid."""
|
||||
valid = False
|
||||
public_key: rsa.RSAPublicKey | None = None
|
||||
try:
|
||||
keybytes = key.encode()
|
||||
public_key = load_public_key(keybytes)
|
||||
except Exception as e:
|
||||
LOG.debug("Validation of public key failed: %s", e, exc_info=True)
|
||||
else:
|
||||
valid = True
|
||||
|
||||
if not isinstance(public_key, rsa.RSAPublicKey):
|
||||
valid = False
|
||||
|
||||
return valid
|
||||
|
||||
|
||||
def load_private_key(filename: str, password: str | None = None) -> rsa.RSAPrivateKey:
|
||||
"""Load a private key."""
|
||||
password_bytes: bytes | None = None
|
||||
if password:
|
||||
password_bytes = password.encode()
|
||||
with open(filename, "rb") as f:
|
||||
private_key = serialization.load_ssh_private_key(f.read(), password=password_bytes)
|
||||
if not isinstance(private_key, rsa.RSAPrivateKey):
|
||||
raise RuntimeError("Only RSA keys are supported.")
|
||||
return private_key
|
||||
|
||||
|
||||
def encrypt_string(string: str, public_key: rsa.RSAPublicKey) -> str:
|
||||
"""Encrypt string, end return it base64 encoded."""
|
||||
message = string.encode()
|
||||
ciphertext = public_key.encrypt(
|
||||
message,
|
||||
padding.PKCS1v15(),
|
||||
)
|
||||
return base64.b64encode(ciphertext).decode()
|
||||
|
||||
|
||||
def decode_string(ciphertext: str, private_key: rsa.RSAPrivateKey) -> str:
|
||||
"""Decode a string. String must be base64 encoded."""
|
||||
decoded = base64.b64decode(ciphertext)
|
||||
decrypted = private_key.decrypt(
|
||||
decoded,
|
||||
padding.PKCS1v15(),
|
||||
)
|
||||
return decrypted.decode()
|
||||
|
||||
|
||||
def generate_private_key() -> rsa.RSAPrivateKey:
|
||||
"""Generate private RSA key."""
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=constants.RSA_PUBLIC_EXPONENT, key_size=constants.RSA_KEY_SIZE
|
||||
)
|
||||
return private_key
|
||||
|
||||
|
||||
def generate_pem(private_key: rsa.RSAPrivateKey) -> str:
|
||||
"""Generate PEM."""
|
||||
pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.OpenSSH,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
return pem.decode()
|
||||
|
||||
|
||||
def create_private_rsa_key(filename: Path, password: str | None = None) -> None:
|
||||
"""Create an RSA Private key at the given path.
|
||||
|
||||
A password may be provided for secure storage.
|
||||
"""
|
||||
if filename.exists():
|
||||
raise RuntimeError("Error: private key file already exists.")
|
||||
LOG.debug("Generating private RSA key at %s", filename)
|
||||
private_key = generate_private_key()
|
||||
encryption_algorithm = serialization.NoEncryption()
|
||||
if password:
|
||||
password_bytes = password.encode()
|
||||
encryption_algorithm = serialization.BestAvailableEncryption(password_bytes)
|
||||
with open(filename, "wb") as f:
|
||||
pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.OpenSSH,
|
||||
encryption_algorithm=encryption_algorithm,
|
||||
)
|
||||
lines = f.write(pem)
|
||||
LOG.debug("Wrote %s lines", lines)
|
||||
f.flush()
|
||||
|
||||
|
||||
def generate_public_key_string(public_key: rsa.RSAPublicKey) -> str:
|
||||
"""Generate public key string."""
|
||||
keybytes = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.OpenSSH,
|
||||
format=serialization.PublicFormat.OpenSSH,
|
||||
)
|
||||
return keybytes.decode()
|
||||
22
packages/sshecret-admin/src/sshecret_admin/db.py
Normal file
22
packages/sshecret-admin/src/sshecret_admin/db.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""Database setup."""
|
||||
|
||||
from collections.abc import Generator, Callable
|
||||
|
||||
from sqlmodel import Session, create_engine
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.engine import URL
|
||||
|
||||
|
||||
def setup_database(
|
||||
db_url: URL | str,
|
||||
) -> tuple[sa.Engine, Callable[[], Generator[Session, None, None]]]:
|
||||
"""Setup database."""
|
||||
|
||||
engine = create_engine(db_url, echo=True)
|
||||
|
||||
def get_db_session() -> Generator[Session, None, None]:
|
||||
"""Get DB Session."""
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
return engine, get_db_session
|
||||
240
packages/sshecret-admin/src/sshecret_admin/frontend.py
Normal file
240
packages/sshecret-admin/src/sshecret_admin/frontend.py
Normal file
@ -0,0 +1,240 @@
|
||||
"""Frontend methods."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
import logging
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import jwt
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
||||
from sqlmodel import Session, select
|
||||
from sshecret_admin.settings import AdminServerSettings
|
||||
from sshecret.backend import SshecretBackend
|
||||
from .admin_backend import AdminBackend
|
||||
from .auth_models import (
|
||||
JWT_ALGORITHM,
|
||||
AuthenticationFailedError,
|
||||
AuthenticationNeededError,
|
||||
LoginError,
|
||||
PasswordDB,
|
||||
User,
|
||||
TokenData,
|
||||
create_access_token,
|
||||
verify_password,
|
||||
)
|
||||
from .types import DBSessionDep
|
||||
from .views import create_audit_view, create_client_view, create_secrets_view
|
||||
|
||||
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 45
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def login_error(templates: Jinja2Blocks, request: Request):
|
||||
"""Return a login error."""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{
|
||||
"page_title": "Login",
|
||||
"page_description": "Login Page",
|
||||
"error": "Invalid Login.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_frontend(
|
||||
settings: AdminServerSettings, get_db_session: DBSessionDep
|
||||
) -> APIRouter:
|
||||
"""Create frontend."""
|
||||
app = APIRouter(include_in_schema=False)
|
||||
|
||||
script_path = Path(os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
template_path = script_path / "templates"
|
||||
|
||||
templates = Jinja2Blocks(directory=template_path)
|
||||
|
||||
# @app.exception_handler(AuthenticationFailedError)
|
||||
# async def handle_authentication_failed(request: Request, exc: AuthenticationFailedError):
|
||||
# """Handle authentication failed error."""
|
||||
# return templates.TemplateResponse(request, "login.html")
|
||||
|
||||
async def get_backend():
|
||||
"""Get backend client."""
|
||||
backend_client = SshecretBackend(
|
||||
str(settings.backend_url), settings.backend_token
|
||||
)
|
||||
yield backend_client
|
||||
|
||||
async def get_admin_backend(session: Annotated[Session, Depends(get_db_session)]):
|
||||
"""Get admin backend API."""
|
||||
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
||||
if not password_db:
|
||||
raise HTTPException(
|
||||
500, detail="Error: The password manager has not yet been set up."
|
||||
)
|
||||
admin = AdminBackend(settings, password_db.encrypted_password)
|
||||
yield admin
|
||||
|
||||
async def get_login_status(
|
||||
request: Request, session: Annotated[Session, Depends(get_db_session)]
|
||||
) -> bool:
|
||||
"""Get login status."""
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
return False
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
return False
|
||||
except jwt.InvalidTokenError:
|
||||
return False
|
||||
token_data = TokenData(username=username)
|
||||
user = session.exec(
|
||||
select(User).where(User.username == token_data.username)
|
||||
).first()
|
||||
if not user:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def get_current_user_from_token(
|
||||
request: Request, session: Annotated[Session, Depends(get_db_session)]
|
||||
) -> User:
|
||||
credentials_exception = AuthenticationNeededError()
|
||||
"""Get current user from token."""
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
raise credentials_exception
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM])
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise credentials_exception
|
||||
except jwt.InvalidTokenError:
|
||||
raise credentials_exception
|
||||
token_data = TokenData(username=username)
|
||||
user = session.exec(
|
||||
select(User).where(User.username == token_data.username)
|
||||
).first()
|
||||
if not user:
|
||||
raise credentials_exception
|
||||
|
||||
return user
|
||||
|
||||
@app.get("/")
|
||||
async def get_index(
|
||||
request: Request,
|
||||
login_status: Annotated[bool, Depends(get_login_status)],
|
||||
error_title: str | None = None,
|
||||
error_message: str | None = None,
|
||||
):
|
||||
"""Get index."""
|
||||
if login_status:
|
||||
return RedirectResponse("/dashboard")
|
||||
login_error: LoginError | None = None
|
||||
if error_title and error_message:
|
||||
login_error = LoginError(title=error_title, message=error_message)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{
|
||||
"page_title": "Login",
|
||||
"page_description": "Login page.",
|
||||
"login_error": login_error,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/")
|
||||
async def post_index(
|
||||
request: Request,
|
||||
error_title: str | None = None,
|
||||
error_message: str | None = None,
|
||||
):
|
||||
"""Get index."""
|
||||
login_error: LoginError | None = None
|
||||
if error_title and error_message:
|
||||
login_error = LoginError(title=error_title, message=error_message)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{
|
||||
"page_title": "Login",
|
||||
"page_description": "Login page.",
|
||||
"login_error": login_error,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/login")
|
||||
async def login_user(
|
||||
response: Response,
|
||||
request: Request,
|
||||
session: Annotated[Session, Depends(get_db_session)],
|
||||
username: Annotated[str, Form()],
|
||||
password: Annotated[str, Form()],
|
||||
):
|
||||
"""Log in user."""
|
||||
user = session.exec(select(User).where(User.username == username)).first()
|
||||
auth_error = AuthenticationFailedError()
|
||||
if not user:
|
||||
raise auth_error
|
||||
|
||||
if not verify_password(password, user.hashed_password):
|
||||
raise auth_error
|
||||
|
||||
token_data = {"sub": user.username}
|
||||
expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
token = create_access_token(settings, token_data, expires_delta=expires)
|
||||
response = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
||||
response.set_cookie(
|
||||
key="access_token", value=token, httponly=True, secure=False, samesite="lax"
|
||||
)
|
||||
return response
|
||||
|
||||
@app.get("/success")
|
||||
async def success_page(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
):
|
||||
"""Display a success page."""
|
||||
return templates.TemplateResponse(
|
||||
request, "success.html", {"page_title": "Success!", "user": current_user}
|
||||
)
|
||||
|
||||
@app.get("/dashboard")
|
||||
async def get_dashboard(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
):
|
||||
"""Dashboard for mocking up the dashboard."""
|
||||
# secrets = await admin.get_secrets()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard.html",
|
||||
{
|
||||
"page_title": "sshecret",
|
||||
"user": current_user.username,
|
||||
},
|
||||
)
|
||||
|
||||
# Stop adding routes here.
|
||||
|
||||
app.include_router(
|
||||
create_client_view(templates, get_current_user_from_token, get_admin_backend)
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
create_secrets_view(templates, get_current_user_from_token, get_admin_backend)
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
create_audit_view(templates, get_current_user_from_token, get_admin_backend)
|
||||
)
|
||||
|
||||
return app
|
||||
@ -7,17 +7,18 @@ from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pykeepass
|
||||
from sshecret_admin.master_password import retrieve_master_password
|
||||
from .master_password import decrypt_master_password
|
||||
from .settings import AdminServerSettings
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
NO_USERNAME = "NO_USERNAME"
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
DEFAULT_LOCATION = "keepass.kdbx"
|
||||
|
||||
|
||||
def create_password_db(location: Path, password: str) -> None:
|
||||
"""Create the password database."""
|
||||
LOG.info("Creating password database at %s", location)
|
||||
@ -64,7 +65,7 @@ class PasswordContext:
|
||||
|
||||
LOG.warning("Secret name %s accessed", entry_name)
|
||||
if password := cast(str, entry.password):
|
||||
return str(entry.password)
|
||||
return str(password)
|
||||
|
||||
raise RuntimeError(f"Cannot get password for entry {entry_name}")
|
||||
|
||||
@ -75,6 +76,17 @@ class PasswordContext:
|
||||
return []
|
||||
return [str(entry.title) for entry in entries]
|
||||
|
||||
def delete_entry(self, entry_name: str) -> None:
|
||||
"""Delete entry."""
|
||||
entry = cast(
|
||||
"pykeepass.entry.Entry | None",
|
||||
self.keepass.find_entries(title=entry_name, first=True),
|
||||
)
|
||||
if not entry:
|
||||
return
|
||||
entry.delete()
|
||||
self.keepass.save()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _password_context(location: Path, password: str) -> Iterator[PasswordContext]:
|
||||
@ -84,16 +96,19 @@ def _password_context(location: Path, password: str) -> Iterator[PasswordContext
|
||||
yield context
|
||||
|
||||
|
||||
|
||||
@contextmanager
|
||||
def load_password_manager(encrypted_password: str, location: str = DEFAULT_LOCATION) -> Iterator[PasswordContext]:
|
||||
def load_password_manager(
|
||||
settings: AdminServerSettings,
|
||||
encrypted_password: str,
|
||||
location: str = DEFAULT_LOCATION,
|
||||
) -> Iterator[PasswordContext]:
|
||||
"""Load password manager.
|
||||
|
||||
This function decrypts the password, and creates the password database if it
|
||||
has not yet been created.
|
||||
"""
|
||||
db_location = Path(location)
|
||||
password = retrieve_master_password(encrypted_password)
|
||||
password = decrypt_master_password(settings=settings, encrypted=encrypted_password)
|
||||
if not db_location.exists():
|
||||
create_password_db(db_location, password)
|
||||
|
||||
|
||||
18
packages/sshecret-admin/src/sshecret_admin/main.py
Normal file
18
packages/sshecret-admin/src/sshecret_admin/main.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""Main server app."""
|
||||
import sys
|
||||
import uvicorn
|
||||
import click
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .app import create_admin_app
|
||||
from .settings import AdminServerSettings
|
||||
|
||||
try:
|
||||
app = create_admin_app(AdminServerSettings()) # pyright: ignore[reportCallIssue]
|
||||
except ValidationError as e:
|
||||
error = click.style("Error", bold=True, fg="red")
|
||||
click.echo(f"{error}: One or more required environment variables are missing.")
|
||||
for error in e.errors():
|
||||
click.echo(f" - {error['loc'][0]}")
|
||||
|
||||
sys.exit(1)
|
||||
@ -1,24 +1,22 @@
|
||||
"""Functions related to handling the password database master password."""
|
||||
|
||||
import secrets
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from sshecret_admin.crypto import (
|
||||
from sshecret.crypto import (
|
||||
create_private_rsa_key,
|
||||
load_private_key,
|
||||
encrypt_string,
|
||||
decode_string,
|
||||
)
|
||||
from sshecret_admin.settings import ServerSettings
|
||||
from .settings import AdminServerSettings
|
||||
|
||||
KEY_FILENAME = "sshecret-admin-key"
|
||||
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
|
||||
def setup_master_password(
|
||||
filename: str = KEY_FILENAME, regenerate: bool = False
|
||||
settings: AdminServerSettings,
|
||||
filename: str = KEY_FILENAME,
|
||||
regenerate: bool = False,
|
||||
) -> str | None:
|
||||
"""Setup master password.
|
||||
|
||||
@ -26,14 +24,16 @@ def setup_master_password(
|
||||
|
||||
This method should run just after setting up the database.
|
||||
"""
|
||||
created = _initial_key_setup(filename, regenerate)
|
||||
created = _initial_key_setup(settings, filename, regenerate)
|
||||
if not created:
|
||||
return None
|
||||
|
||||
return _generate_master_password(filename)
|
||||
return _generate_master_password(settings, filename)
|
||||
|
||||
|
||||
def decrypt_master_password(encrypted: str, filename: str = KEY_FILENAME) -> str:
|
||||
def decrypt_master_password(
|
||||
settings: AdminServerSettings, encrypted: str, filename: str = KEY_FILENAME
|
||||
) -> str:
|
||||
"""Retrieve master password."""
|
||||
keyfile = Path(filename)
|
||||
if not keyfile.exists():
|
||||
@ -48,21 +48,27 @@ def _generate_password() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _initial_key_setup(filename: str = KEY_FILENAME, regenerate: bool = False) -> bool:
|
||||
def _initial_key_setup(
|
||||
settings: AdminServerSettings,
|
||||
filename: str = KEY_FILENAME,
|
||||
regenerate: bool = False,
|
||||
) -> bool:
|
||||
"""Set up initial keys."""
|
||||
keyfile = Path(filename)
|
||||
|
||||
if keyfile.exists() and not regenerate:
|
||||
return False
|
||||
|
||||
assert (
|
||||
settings.secret_key is not None
|
||||
), "Error: Could not load a secret key from environment."
|
||||
assert settings.secret_key is not None, (
|
||||
"Error: Could not load a secret key from environment."
|
||||
)
|
||||
create_private_rsa_key(keyfile, password=settings.secret_key)
|
||||
return True
|
||||
|
||||
|
||||
def _generate_master_password(filename: str = KEY_FILENAME) -> str:
|
||||
def _generate_master_password(
|
||||
settings: AdminServerSettings, filename: str = KEY_FILENAME
|
||||
) -> str:
|
||||
"""Generate master password for password database.
|
||||
|
||||
Returns the encrypted string, base64 encoded.
|
||||
|
||||
0
packages/sshecret-admin/src/sshecret_admin/py.typed
Normal file
0
packages/sshecret-admin/src/sshecret_admin/py.typed
Normal file
@ -1,23 +1,25 @@
|
||||
"""SSH Server settings."""
|
||||
|
||||
from pathlib import Path
|
||||
from pydantic import AnyHttpUrl, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
DEFAULT_LISTEN_PORT = 8822
|
||||
|
||||
DEFAULT_DB = "ssh_admin.db"
|
||||
DEFAULT_DATABASE = "sqlite:///ssh_admin.db"
|
||||
|
||||
class ServerSettings(BaseSettings):
|
||||
|
||||
class AdminServerSettings(BaseSettings):
|
||||
"""Server Settings."""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".admin.env", env_prefix="sshecret_admin_", secrets_dir='/var/run')
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".admin.env", env_prefix="sshecret_admin_", secrets_dir="/var/run"
|
||||
)
|
||||
|
||||
backend_url: AnyHttpUrl = Field(alias="sshecret_backend_url")
|
||||
backend_token: str
|
||||
listen_address: str = Field(default="")
|
||||
secret_key: str
|
||||
port: int = DEFAULT_LISTEN_PORT
|
||||
admin_db: Path = Path(DEFAULT_DB)
|
||||
admin_db: str = Field(default=DEFAULT_DATABASE)
|
||||
debug: bool = False
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "../node_modules/flowbite";
|
||||
@source "../node_modules/flowbite-datepicker";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-primary-50: #eff6ff;
|
||||
--color-primary-100: #dbeafe;
|
||||
--color-primary-200: #bfdbfe;
|
||||
--color-primary-300: #93c5fd;
|
||||
--color-primary-400: #60a5fa;
|
||||
--color-primary-500: #3b82f6;
|
||||
--color-primary-600: #2563eb;
|
||||
--color-primary-700: #1d4ed8;
|
||||
--color-primary-800: #1e40af;
|
||||
--color-primary-900: #1e3a8a;
|
||||
|
||||
--font-sans: "Inter", "ui-sans-serif", "system-ui", "-apple-system",
|
||||
"system-ui", "Segoe UI", "Roboto", "Helvetica Neue", "Arial",
|
||||
"Noto Sans", "sans-serif", "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-body: "Inter", "ui-sans-serif", "system-ui", "-apple-system",
|
||||
"system-ui", "Segoe UI", "Roboto", "Helvetica Neue", "Arial",
|
||||
"Noto Sans", "sans-serif", "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-mono: "ui-monospace", "SFMono-Regular", "Menlo", "Monaco",
|
||||
"Consolas", "Liberation Mono", "Courier New", "monospace";
|
||||
}
|
||||
3935
packages/sshecret-admin/src/sshecret_admin/static/css/main.css
Normal file
3935
packages/sshecret-admin/src/sshecret_admin/static/css/main.css
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,3 @@
|
||||
/* PrismJS 1.30.0
|
||||
https://prismjs.com/download.html#themes=prism&languages=markup+css+json */
|
||||
code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}
|
||||
23
packages/sshecret-admin/src/sshecret_admin/static/index.html
Normal file
23
packages/sshecret-admin/src/sshecret_admin/static/index.html
Normal file
@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html class="no-js" lang="">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>Static in lib</title>
|
||||
<meta name="description" content="" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<!-- Place favicon.ico in the root directory -->
|
||||
</head>
|
||||
<body>
|
||||
<!--[if lt IE 8]>
|
||||
<p class="browserupgrade">
|
||||
You are using an <strong>outdated</strong> browser. Please
|
||||
<a href="http://browsehappy.com/">upgrade your browser</a> to improve
|
||||
your experience.
|
||||
</p>
|
||||
<![endif]-->
|
||||
<h1>I'm inside the package</h1>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,54 @@
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
|
||||
if (sidebar) {
|
||||
const toggleSidebarMobile = (
|
||||
sidebar,
|
||||
sidebarBackdrop,
|
||||
toggleSidebarMobileHamburger,
|
||||
toggleSidebarMobileClose,
|
||||
) => {
|
||||
sidebar.classList.toggle("hidden");
|
||||
sidebarBackdrop.classList.toggle("hidden");
|
||||
toggleSidebarMobileHamburger.classList.toggle("hidden");
|
||||
toggleSidebarMobileClose.classList.toggle("hidden");
|
||||
};
|
||||
|
||||
const toggleSidebarMobileEl = document.getElementById("toggleSidebarMobile");
|
||||
const sidebarBackdrop = document.getElementById("sidebarBackdrop");
|
||||
const toggleSidebarMobileHamburger = document.getElementById(
|
||||
"toggleSidebarMobileHamburger",
|
||||
);
|
||||
const toggleSidebarMobileClose = document.getElementById(
|
||||
"toggleSidebarMobileClose",
|
||||
);
|
||||
// const toggleSidebarMobileSearch = document.getElementById(
|
||||
// "toggleSidebarMobileSearch",
|
||||
// );
|
||||
|
||||
// toggleSidebarMobileSearch.addEventListener("click", () => {
|
||||
// toggleSidebarMobile(
|
||||
// sidebar,
|
||||
// sidebarBackdrop,
|
||||
// toggleSidebarMobileHamburger,
|
||||
// toggleSidebarMobileClose,
|
||||
// );
|
||||
// });
|
||||
|
||||
toggleSidebarMobileEl.addEventListener("click", () => {
|
||||
toggleSidebarMobile(
|
||||
sidebar,
|
||||
sidebarBackdrop,
|
||||
toggleSidebarMobileHamburger,
|
||||
toggleSidebarMobileClose,
|
||||
);
|
||||
});
|
||||
|
||||
// sidebarBackdrop.addEventListener("click", () => {
|
||||
// toggleSidebarMobile(
|
||||
// sidebar,
|
||||
// sidebarBackdrop,
|
||||
// toggleSidebarMobileHamburger,
|
||||
// toggleSidebarMobileClose,
|
||||
// );
|
||||
// });
|
||||
}
|
||||
20
packages/sshecret-admin/src/sshecret_admin/static/logo.svg
Normal file
20
packages/sshecret-admin/src/sshecret_admin/static/logo.svg
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 504 494" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(1,0,0,1,-300,-220)">
|
||||
<g transform="matrix(1.60539,0,0,1.60539,-319.922,-249.523)">
|
||||
<g transform="matrix(1,0,0,1,8.07567,42.4671)">
|
||||
<path d="M535,250L561.806,335L613.462,308.734L605.179,367.467L691.924,367.467L621.746,420L661.954,462.5L605.179,472.533L631.985,557.533L561.806,505L535,557.533L508.194,505L438.015,557.533L464.821,472.533L408.046,462.5L448.254,420L378.076,367.467L464.821,367.467L456.538,308.734L508.194,335L535,250Z"/>
|
||||
</g>
|
||||
<g transform="matrix(0.8125,0,0,0.8125,331.826,19.4716)">
|
||||
<path d="M246.833,633.448C236.413,655.429 209.771,670 180,670L180,557.533C209.771,557.533 236.413,572.104 246.833,594.085L275.336,594.085C285.418,572.104 311.195,557.533 340,557.533L340,670C311.195,670 285.418,655.429 275.336,633.448L246.833,633.448Z" style="fill:rgb(180,17,4);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,258.076,2.46711)">
|
||||
<ellipse cx="250" cy="415" rx="20" ry="15" style="fill:white;"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,328.076,2.46711)">
|
||||
<ellipse cx="250" cy="415" rx="20" ry="15" style="fill:white;"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@ -0,0 +1,31 @@
|
||||
<tr
|
||||
class="hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
id="entry-{{ entry.id }}"
|
||||
>
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
||||
>
|
||||
{{ entry.timestamp }}
|
||||
</td>
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
||||
>
|
||||
{{ entry.subsystem }}
|
||||
</td>
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
||||
>
|
||||
<pre><code class="language-json">
|
||||
{%- set entry_object = ({"object": entry.object, "object_id": entry.object_id, "client_id": entry.client_id, "client_name": entry.client_name}) -%}
|
||||
{{- entry_object | tojson(indent=2) -}}</code></pre>
|
||||
</td>
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
||||
>
|
||||
{{ entry.message }}
|
||||
</td>
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
||||
>
|
||||
{{ entry.origin }}
|
||||
</td>
|
||||
@ -0,0 +1,61 @@
|
||||
{% extends "/dashboard/_base.html" %} {% block content %}
|
||||
<div
|
||||
class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700"
|
||||
>
|
||||
<div class="w-full mb-1">
|
||||
<div class="mb-4">
|
||||
<nav class="flex mb-5" aria-label="Breadcrumb">
|
||||
<ol
|
||||
class="inline-flex items-center space-x-1 text-sm font-medium md:space-x-2"
|
||||
>
|
||||
<li class="inline-flex items-center">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-white"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"
|
||||
></path>
|
||||
</svg>
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<svg
|
||||
class="w-6 h-6 text-gray-800 dark:text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 8h6m-6 4h6m-6 4h6M6 3v18l2-2 2 2 2-2 2 2 2-2 2 2V3l-2 2-2-2-2 2-2-2-2 2-2-2Z"
|
||||
/>
|
||||
|
||||
<span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Audit Log</span>
|
||||
</svg>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Audit Log</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="auditContent">
|
||||
{% include 'audit/inner.html.j2' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -0,0 +1,55 @@
|
||||
<div>
|
||||
<div class="flex flex-col">
|
||||
<div class="overflow-x-auto">
|
||||
<div class="inline-block min-w-full align-middle">
|
||||
<div class="overflow-hidden shadow">
|
||||
<table
|
||||
class="min-w-full divide-y divide-gray-200 table-fixed dark:divide-gray-600"
|
||||
>
|
||||
<thead class="bg-gray-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Timestamp
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Subsystem
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Object
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Message
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Origin
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700"
|
||||
>
|
||||
{% for entry in entries %} {% include 'audit/entry.html.j2' %} {%
|
||||
endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'audit/pagination.html.j2' %}
|
||||
</div>
|
||||
@ -0,0 +1,55 @@
|
||||
<div>
|
||||
<div class="flex flex-col">
|
||||
<div class="overflow-x-auto">
|
||||
<div class="inline-block min-w-full align-middle">
|
||||
<div class="overflow-hidden shadow">
|
||||
<table
|
||||
class="min-w-full divide-y divide-gray-200 table-fixed dark:divide-gray-600"
|
||||
>
|
||||
<thead class="bg-gray-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
ID
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Operation
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Client Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Message
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Origin
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700"
|
||||
>
|
||||
{% for entry in entries %} {% include 'audit/entry.html.j2' %} {%
|
||||
endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'audit/pagination.html.j2' %}
|
||||
</div>
|
||||
@ -0,0 +1,67 @@
|
||||
|
||||
<div
|
||||
class="sticky bottom-0 right-0 items-center w-full p-4 bg-white border-t border-gray-200 sm:flex sm:justify-between dark:bg-gray-800 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex items-center mb-4 sm:mb-0">
|
||||
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400"
|
||||
>Showing
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{page_info.first }}-{{ page_info.last}}</span> of
|
||||
<span class="font-semibold text-gray-900 dark:text-white"
|
||||
>{{ page_info.total }}</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex space-x-1">
|
||||
|
||||
<button
|
||||
{% if page_info.page == 1 %}
|
||||
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-gray-900 bg-white border border-gray-300 rounded hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 transition duration-200 ease"
|
||||
disabled=""
|
||||
{% else %}
|
||||
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800 transition duration-200 ease"
|
||||
hx-get="/audit/page/{{ page_info.page - 1 }}"
|
||||
hx-target="#auditContent"
|
||||
hx-push-url="true"
|
||||
{% endif %}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{% for n in range(page_info.total_pages) %}
|
||||
{% set p = n + 1 %}
|
||||
{% if p == page_info.page %}
|
||||
<button
|
||||
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800 transition duration-200 ease">
|
||||
|
||||
{{ p }}
|
||||
</button>
|
||||
{% else %}
|
||||
<button
|
||||
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-gray-900 bg-white border border-gray-300 rounded hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 transition duration-200 ease"
|
||||
|
||||
hx-get="/audit/page/{{ p }}"
|
||||
hx-target="#auditContent"
|
||||
hx-push-url="true"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<button
|
||||
{% if page_info.page < page_info.total_pages %}
|
||||
|
||||
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800 transition duration-200 ease"
|
||||
|
||||
hx-get="/audit/page/{{ page_info.page + 1 }}"
|
||||
hx-target="#auditContent"
|
||||
hx-push-url="true"
|
||||
{% else %}
|
||||
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-gray-900 bg-white border border-gray-300 rounded hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 transition duration-200 ease"
|
||||
disabled=""
|
||||
{% endif %}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,82 @@
|
||||
<tr
|
||||
class="hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
id="client-{{ client.id }}"
|
||||
>
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
||||
>
|
||||
{{ client.name }}
|
||||
</td>
|
||||
<td
|
||||
class="p-4 text-base font-medium text-gray-900 whitespace-nowrap dark:text-white"
|
||||
>
|
||||
{{ client.id }}
|
||||
</td>
|
||||
<td
|
||||
class="max-w-sm p-4 overflow-hidden text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400"
|
||||
>
|
||||
{{ client.description }}
|
||||
</td>
|
||||
<td
|
||||
class="max-w-sm p-4 text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400"
|
||||
>
|
||||
{{ client.secrets|length }}
|
||||
</td>
|
||||
<td
|
||||
class="max-w-sm p-4 text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400"
|
||||
>
|
||||
{{ client.policies|join(', ') }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 space-x-2 whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
id="updateClientButton"
|
||||
data-drawer-target="drawer-update-client-{{ client.id }}"
|
||||
data-drawer-show="drawer-update-client-{{ client.id }}"
|
||||
aria-controls="drawer-update-client-{{ client.id }}"
|
||||
data-drawer-placement="right"
|
||||
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"
|
||||
></path>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="deleteClientButton"
|
||||
data-drawer-target="drawer-delete-client-{{ client.id }}"
|
||||
data-drawer-show="drawer-delete-client-{{ client.id }}"
|
||||
aria-controls="drawer-delete-client-{{ client.id }}"
|
||||
data-drawer-placement="right"
|
||||
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-red-700 rounded-lg hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
Delete item
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@ -0,0 +1,145 @@
|
||||
<div
|
||||
id="drawer-create-client-default"
|
||||
class="fixed top-0 right-0 z-40 w-full h-screen max-w-xs p-4 overflow-y-auto transition-transform translate-x-full bg-white dark:bg-gray-800"
|
||||
tabindex="-1"
|
||||
aria-labelledby="drawer-label"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<h5
|
||||
id="drawer-label"
|
||||
class="inline-flex items-center mb-6 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
New Client
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
data-drawer-dismiss="drawer-create-client-default"
|
||||
aria-controls="drawer-create-client-default"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="sr-only">Close menu</span>
|
||||
</button>
|
||||
<form hx-post="/clients/" hx-target="#clientContent">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
placeholder="Client name"
|
||||
required=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="description"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>Description</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="description"
|
||||
id="description"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
placeholder="Client description"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="sources"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>Allowed subnets or IPs</label
|
||||
>
|
||||
<p
|
||||
id="helper-text-explanation"
|
||||
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Separate multiple entries with comma.
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="sources"
|
||||
id="sources"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
placeholder="0.0.0.0/0"
|
||||
value="0.0.0.0/0"
|
||||
hx-post="/clients/validate/source"
|
||||
hx-target="#clientSourceValidation"
|
||||
/>
|
||||
<span id="clientSourceValidation"></span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="public_key"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>Public Key</label
|
||||
>
|
||||
<textarea
|
||||
id="public_key"
|
||||
name="public_key"
|
||||
rows="4"
|
||||
class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
placeholder="Enter RSA SSH Public Key here"
|
||||
hx-post="/clients/validate/public_key"
|
||||
hx-target="#clientPublicKeyValidation"
|
||||
></textarea>
|
||||
<span id="clientPublicKeyValidation"></span>
|
||||
</div>
|
||||
<div
|
||||
class="bottom-0 left-0 flex justify-center w-full pb-4 space-x-4 md:px-4 md:absolute"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||
>
|
||||
Add Client
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-drawer-dismiss="drawer-create-client-default"
|
||||
aria-controls="drawer-create-client-default"
|
||||
class="inline-flex w-full justify-center text-gray-500 items-center bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-primary-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-5 h-5 -ml-1 sm:mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -0,0 +1,67 @@
|
||||
<div
|
||||
id="drawer-delete-client-{{ client.id }}"
|
||||
class="fixed top-0 right-0 z-40 w-full h-screen max-w-xs p-4 overflow-y-auto transition-transform translate-x-full bg-white dark:bg-gray-800"
|
||||
tabindex="-1"
|
||||
aria-labelledby="drawer-label"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<h5
|
||||
id="drawer-label"
|
||||
class="inline-flex items-center text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Delete Client {{client.name}}
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
data-drawer-dismiss="drawer-delete-client-{{ client.id }}"
|
||||
aria-controls="drawer-delete-client-{{ client.id }}"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="sr-only">Close menu</span>
|
||||
</button>
|
||||
<svg
|
||||
class="w-10 h-10 mt-8 mb-4 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<h3 class="mb-6 text-lg text-gray-500 dark:text-gray-400">
|
||||
Are you sure you want to delete this client?
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm inline-flex items-center px-3 py-2.5 text-center mr-2 dark:focus:ring-red-900"
|
||||
hx-delete="/clients/{{ client.id }}"
|
||||
hx-target="#clientContent"
|
||||
>
|
||||
Yes, delete the client
|
||||
</button>
|
||||
<a
|
||||
href="#"
|
||||
class="text-gray-900 bg-white hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 border border-gray-200 font-medium inline-flex items-center rounded-lg text-sm px-3 py-2.5 text-center dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-gray-700"
|
||||
data-drawer-hide="drawer-delete-client-{{ client.id }}"
|
||||
>
|
||||
No, cancel
|
||||
</a>
|
||||
</div>
|
||||
@ -0,0 +1,173 @@
|
||||
<div
|
||||
id="drawer-update-client-{{ client.id }}"
|
||||
class="fixed top-0 right-0 z-40 w-full h-screen max-w-xs p-4 overflow-y-auto transition-transform translate-x-full bg-white dark:bg-gray-800"
|
||||
tabindex="-1"
|
||||
aria-labelledby="drawer-label-{{ client.id }}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<h5
|
||||
id="drawer-label-{{ client.id }}"
|
||||
class="inline-flex items-center mb-6 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
<div role="status" class="mr-2 htmx-indicator" id="spinner-{{ client.id}}">
|
||||
<svg aria-hidden="true" class="inline w-4 h-4 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
|
||||
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
|
||||
</svg>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
Update Client
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
data-drawer-dismiss="drawer-update-client-{{ client.id }}"
|
||||
aria-controls="drawer-update-client-{{ client.id }}"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="sr-only">Close menu</span>
|
||||
</button>
|
||||
<form
|
||||
hx-put="/clients/{{ client.id }}"
|
||||
hx-target="#clientContent"
|
||||
hx-indicator="spinner-{{ client.id }}"
|
||||
>
|
||||
<input type="hidden" name="id" value="{{ client.id }}" />
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name-{{ client.id }}"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
placeholder="Client name"
|
||||
value="{{ client.name }}"
|
||||
required=""
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="description"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>Description</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="description"
|
||||
id="description-{{ client.id }}"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
placeholder="Client description"
|
||||
value="{{ client.description}}"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="sources"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>Allowed subnets or IPs</label
|
||||
>
|
||||
<p
|
||||
id="helper-text-explanation-{{ client.id }}"
|
||||
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Separate multiple entries with comma.
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="sources"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
placeholder="0.0.0.0/0"
|
||||
id="sources-{{client.id}}"
|
||||
hx-post="/clients/validate/source"
|
||||
hx-target="#clientSourceValidation-{{ client.id }}"
|
||||
value="{{ client.policies|join(", ") }}"
|
||||
/>
|
||||
|
||||
<span id="clientSourceValidation-{{ client.id }}"></span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="public_key"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>Public Key</label
|
||||
>
|
||||
<p
|
||||
id="helper-text-explanation-{{ client.id }}"
|
||||
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Note that updating the key will invalidate all secrets associated with
|
||||
this client.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
id="public_key-{{ client.id }}"
|
||||
name="public_key"
|
||||
rows="14"
|
||||
class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
placeholder="Enter RSA SSH Public Key here"
|
||||
hx-post="/clients/validate/public_key"
|
||||
hx-indicator="spinner-{{ client.id }}"
|
||||
hx-target="#clientPublicKeyValidation-{{ client.id }}"
|
||||
>
|
||||
{{- client.public_key -}}</textarea
|
||||
>
|
||||
<span id="clientPublicKeyValidation-{{ client.id }}"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<div
|
||||
class="bottom-0 left-0 flex justify-center w-full pb-4 mt-4 space-x-4 sm:absolute sm:px-4 sm:mt-0"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full justify-center text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full justify-center text-red-600 inline-flex items-center hover:text-white border border-red-600 hover:bg-red-600 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:border-red-500 dark:text-red-500 dark:hover:text-white dark:hover:bg-red-600 dark:focus:ring-red-900"
|
||||
hx-delete="/clients/{{ client.id }}"
|
||||
hx-confirm="Are you sure?"
|
||||
hx-target="#clientContent"
|
||||
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-5 h-5 mr-1 -ml-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -0,0 +1,3 @@
|
||||
<template>
|
||||
{% include '/clients/inner.html.j2' %}
|
||||
</template>
|
||||
@ -0,0 +1 @@
|
||||
<p class="mt-2 text-sm text-red-600 dark:text-red-500"><span class="font-medium">Invalid value. </span> {{explanation}}.</p>
|
||||
@ -0,0 +1 @@
|
||||
<span></span>
|
||||
@ -0,0 +1,45 @@
|
||||
{% extends "/dashboard/_base.html" %} {% block content %}
|
||||
<div class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="w-full mb-1">
|
||||
<div class="mb-4">
|
||||
<nav class="flex mb-5" aria-label="Breadcrumb">
|
||||
<ol class="inline-flex items-center space-x-1 text-sm font-medium md:space-x-2">
|
||||
<li class="inline-flex items-center">
|
||||
<a href="/" class="inline-flex items-center text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-white">
|
||||
<svg class="w-5 h-5 mr-2.5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg>
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
<div class="flex items-center">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
|
||||
<span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Clients</span>
|
||||
</svg>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Clients</h1>
|
||||
</div>
|
||||
|
||||
<div class="items-center justify-between block sm:flex">
|
||||
<div class="flex items-center mb-4 sm:mb-0">
|
||||
<label for="client-search" class="sr-only">Search</label>
|
||||
<div class="relative w-48 mt-1 sm:w-64 xl:w-96">
|
||||
<input type="search" name="query" id="client-search" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" placeholder="Search for clients" hx-post="/clients/query" hx-trigger="keyup changed delay:500ms, query" hx-target="#clientContent">
|
||||
</div>
|
||||
</div>
|
||||
<button id="createClientButton" class="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800" type="button" data-drawer-target="drawer-create-client-default" data-drawer-show="drawer-create-client-default" aria-controls="drawer-create-client-default" data-drawer-placement="right">
|
||||
Add new client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div id="clientContent">
|
||||
{% include '/clients/inner.html.j2' %}
|
||||
</div>
|
||||
|
||||
{% include '/clients/drawer_client_create.html.j2' %}
|
||||
{% endblock %}
|
||||
@ -0,0 +1,48 @@
|
||||
<div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="overflow-x-auto">
|
||||
<div class="inline-block min-w-full align-middle">
|
||||
<div class="overflow-hidden shadow">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-fixed dark:divide-gray-600">
|
||||
<thead class="bg-gray-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
|
||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
||||
ID
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
||||
Description
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
||||
Number of secrets allocated
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
||||
Allowed Sources
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700">
|
||||
{% for client in clients %}
|
||||
{% include '/clients/client.html.j2'%}
|
||||
{% endfor %}
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for client in clients %}
|
||||
{% include '/clients/drawer_client_update.html.j2' %}
|
||||
{% include '/clients/drawer_client_delete.html.j2' %}
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
@ -0,0 +1,10 @@
|
||||
{% extends "/dashboard/_base.html" %} {% block content %}
|
||||
|
||||
<div
|
||||
class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700"
|
||||
>
|
||||
<h1 class="mb-4 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white">Welcome to Sshecret</h1>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
{% include '/dashboard/_header.html' %}
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-gray-800">
|
||||
{% if not hide_elements %}
|
||||
{% include '/dashboard/navbar.html' %}
|
||||
{% endif %}
|
||||
<div class="flex pt-16 overflow-hidden bg-gray-50 dark:bg-gray-900">
|
||||
{% if not hide_elements %}
|
||||
{% include '/dashboard/sidebar.html' %}
|
||||
{% endif %}
|
||||
|
||||
<div id="main-content" class="relative w-full h-full overflow-y-auto bg-gray-50 lg:ml-64 dark:bg-gray-900">
|
||||
<main>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{% include '/dashboard/_scripts.html' %}
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1 @@
|
||||
<!-- todo -->
|
||||
@ -0,0 +1,21 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="{{ page_description }}" />
|
||||
|
||||
<title>{{page_title}}</title>
|
||||
|
||||
{% include '/dashboard/_stylesheet.html' %} {% include
|
||||
'/dashboard/_favicons.html' %}
|
||||
|
||||
<script>
|
||||
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
|
||||
if (
|
||||
localStorage.getItem("color-theme") === "dark" ||
|
||||
(!("color-theme" in localStorage) &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,14 @@
|
||||
<script src="{{ url_for('static', path='js/sidebar.js') }}"></script>
|
||||
<script async defer src="https://buttons.github.io/buttons.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/simple-datatables@9.0.3"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.js"></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script type="text/javascript" src="{{ url_for('static', path="js/prism.js") }}"></script>
|
||||
|
||||
<script>
|
||||
document.body.addEventListener("htmx:afterSwap", () => {
|
||||
if (typeof window.initFlowbite === "function") {
|
||||
window.initFlowbite();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,21 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('static', path='css/main.css') }}"
|
||||
type="text/css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('static', path='css/prism.css') }}"
|
||||
type="text/css"
|
||||
/>
|
||||
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
@ -0,0 +1,47 @@
|
||||
<nav class="fixed z-30 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="px-3 py-3 lg:px-5 lg:pl-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-start">
|
||||
<button id="toggleSidebarMobile" aria-expanded="true" aria-controls="sidebar" class="p-2 text-gray-600 rounded cursor-pointer lg:hidden hover:text-gray-900 hover:bg-gray-100 focus:bg-gray-100 dark:focus:bg-gray-700 focus:ring-2 focus:ring-gray-100 dark:focus:ring-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||
<svg id="toggleSidebarMobileHamburger" class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h6a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path></svg>
|
||||
<svg id="toggleSidebarMobileClose" class="hidden w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
|
||||
</button>
|
||||
<a href="/" class="flex ml-2 md:mr-24">
|
||||
<img src="{{ url_for('static', path='logo.svg') }}" class="h-11 mr-3" alt="Sshecret Logo" />
|
||||
<span class="self-center text-xl font-semibold sm:text-2xl whitespace-nowrap dark:text-white">Sshecret</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center ml-3">
|
||||
<div>
|
||||
<button type="button" class="flex text-sm bg-gray-800 rounded-full focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600" id="user-menu-button-2" aria-expanded="false" data-dropdown-toggle="dropdown-2">
|
||||
<span class="sr-only">Open user menu</span>
|
||||
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0 0a8.949 8.949 0 0 0 4.951-1.488A3.987 3.987 0 0 0 13 16h-2a3.987 3.987 0 0 0-3.951 3.512A8.948 8.948 0 0 0 12 21Zm3-11a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
|
||||
</svg>
|
||||
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<!-- Dropdown menu -->
|
||||
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded shadow dark:bg-gray-700 dark:divide-gray-600" id="dropdown-2">
|
||||
<div class="px-4 py-3" role="none">
|
||||
<p class="text-sm text-gray-900 dark:text-white" role="none">
|
||||
{{ user }}
|
||||
</p>
|
||||
</div>
|
||||
<ul class="py-1" role="none">
|
||||
<li>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white" role="menuitem">Change Password</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white" role="menuitem">Logout</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@ -0,0 +1,112 @@
|
||||
<aside
|
||||
id="sidebar"
|
||||
class="fixed top-0 left-0 z-20 flex flex-col flex-shrink-0 hidden w-64 h-full pt-16 font-normal duration-75 lg:flex transition-width"
|
||||
aria-label="Sidebar"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-col flex-1 min-h-0 pt-0 bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex flex-col flex-1 pt-5 pb-4 overflow-y-auto">
|
||||
<div
|
||||
class="flex-1 px-3 space-y-1 bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700"
|
||||
>
|
||||
<ul class="pb-2 space-y-2">
|
||||
<!-- This is the menu -->
|
||||
|
||||
<li>
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center p-2 text-base text-gray-900 rounded-lg hover:bg-gray-100 group dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-gray-500 transition duration-75 group-hover:text-gray-900 dark:text-gray-400 dark:group-hover:text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"></path>
|
||||
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"></path>
|
||||
</svg>
|
||||
<span class="ml-3" sidebar-toggle-item>Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/clients"
|
||||
class="flex items-center p-2 text-base text-gray-900 rounded-lg hover:bg-gray-100 group dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-gray-800 dark:text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 6H5m2 3H5m2 3H5m2 3H5m2 3H5m11-1a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2M7 3h11a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm8 7a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="ml-3" sidebar-toggle-item>Clients</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/secrets"
|
||||
class="flex items-center p-2 text-base text-gray-900 rounded-lg hover:bg-gray-100 group dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-gray-800 dark:text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18 9V4a1 1 0 0 0-1-1H8.914a1 1 0 0 0-.707.293L4.293 7.207A1 1 0 0 0 4 7.914V20a1 1 0 0 0 1 1h6M9 3v4a1 1 0 0 1-1 1H4m11 13a11.426 11.426 0 0 1-3.637-3.99A11.139 11.139 0 0 1 10 11.833L15 10l5 1.833a11.137 11.137 0 0 1-1.363 5.176A11.425 11.425 0 0 1 15.001 21Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="ml-3" sidebar-toggle-item>Secrets</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/audit"
|
||||
class="flex items-center p-2 text-base text-gray-900 rounded-lg hover:bg-gray-100 group dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-gray-800 dark:text-white"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 8h6m-6 4h6m-6 4h6M6 3v18l2-2 2 2 2-2 2 2 2-2 2 2V3l-2 2-2-2-2 2-2-2-2 2-2-2Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="ml-3" sidebar-toggle-item>Audit</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@ -0,0 +1,71 @@
|
||||
{% extends "/dashboard/_base.html" %} {% block content %}
|
||||
|
||||
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
|
||||
<div class="pb-4 bg-white dark:bg-gray-900">
|
||||
<label for="table-search" class="sr-only">Search</label>
|
||||
<div class="relative mt-1">
|
||||
<div
|
||||
class="absolute inset-y-0 rtl:inset-r-0 start-0 flex items-center ps-3 pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-500 dark:text-gray-400"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="table-search"
|
||||
class="block pt-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg w-80 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
placeholder="Search for items"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<table
|
||||
class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<thead
|
||||
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3">Client Name</th>
|
||||
<th scope="col" class="px-6 py-3">Description</th>
|
||||
<th scope="col" class="px-6 py-3">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for client in clients %}
|
||||
<tr
|
||||
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"
|
||||
>
|
||||
{{ client.name }}
|
||||
</th>
|
||||
<td class="px-6 py-4">{{ client.description }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<a
|
||||
href="#"
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
||||
>Edit</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@ -0,0 +1,3 @@
|
||||
<p class="mt-2 text-sm text-green-600 dark:text-red-500">
|
||||
<span class="font-medium">{{ message }}</span>
|
||||
</p>
|
||||
@ -0,0 +1,3 @@
|
||||
<p class="mt-2 text-sm text-green-600 dark:text-green-500">
|
||||
<span class="font-medium">{{ message }}</span>
|
||||
</p>
|
||||
@ -0,0 +1,55 @@
|
||||
{% extends "/shared/_base.html" %} {% block content %}
|
||||
{% if login_error %}
|
||||
|
||||
<div class="flex bg-gray-100">
|
||||
<div class="flex w-full items-center p-4 mb-4 text-sm text-red-800 border border-red-300 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400 dark:border-red-800" role="alert">
|
||||
|
||||
<svg class="shrink-0 inline w-4 h-4 me-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/>
|
||||
</svg>
|
||||
<span class="sr-only">Info</span>
|
||||
<div>
|
||||
<span class="font-medium">{{ login_error.title }}</span> {{login_error.message}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="min-h-screen bg-gray-100 flex items-center justify-center p-4">
|
||||
<div class="max-w-md w-full bg-white rounded-xl shadow-lg p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">Sign In</h2>
|
||||
<form class="space-y-4" action="/login" method="POST">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Username</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
|
||||
placeholder="Username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Password</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@ -0,0 +1,3 @@
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}">{{ client.name }}</option>
|
||||
{% endfor %}
|
||||
@ -0,0 +1,155 @@
|
||||
<div
|
||||
id="drawer-create-secret-default"
|
||||
class="fixed top-0 right-0 z-40 w-full h-screen max-w-xs p-4 overflow-y-auto transition-transform translate-x-full bg-white dark:bg-gray-800"
|
||||
tabindex="-1"
|
||||
aria-labelledby="drawer-label"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<h5
|
||||
id="drawer-label"
|
||||
class="inline-flex items-center mb-6 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
New Secret
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
data-drawer-dismiss="drawer-create-secret-default"
|
||||
aria-controls="drawer-create-secret-default"
|
||||
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="sr-only">Close menu</span>
|
||||
</button>
|
||||
<form hx-post="/secrets/" hx-target="#secretsContent">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
placeholder="Secret name"
|
||||
required=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="value"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>Secret Value</label
|
||||
>
|
||||
<p
|
||||
id="helper-text-explanation"
|
||||
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Enter the secret string here.
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="value"
|
||||
id="secretValueInput"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
placeholder="Your secret string here"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="auto_generate"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
<label class="inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="auto_generate"
|
||||
id="autoGenerateCheckbox"
|
||||
class="sr-only peer"
|
||||
hx-on:change="document.getElementById('secretValueInput').disabled = this.checked;
|
||||
if (this.checked) { document.getElementById('secretValueInput').value = '' }"
|
||||
/>
|
||||
<div
|
||||
class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"
|
||||
></div>
|
||||
|
||||
<span
|
||||
class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300"
|
||||
>Auto-generate secret</span
|
||||
>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="clients"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>Clients</label
|
||||
>
|
||||
|
||||
<select
|
||||
multiple="multiple"
|
||||
id="clients"
|
||||
name="clients"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
>
|
||||
<option selected="selected">Select clients to assign the secret to</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}">{{ client.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bottom-0 left-0 flex justify-center w-full pb-4 space-x-4 md:px-4 md:absolute"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||
>
|
||||
Add Secret
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-drawer-dismiss="drawer-create-secret-default"
|
||||
aria-controls="drawer-create-secret-default"
|
||||
class="inline-flex w-full justify-center text-gray-500 items-center bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-primary-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-5 h-5 -ml-1 sm:mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -0,0 +1,45 @@
|
||||
{% extends "/dashboard/_base.html" %} {% block content %}
|
||||
<div class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="w-full mb-1">
|
||||
<div class="mb-4">
|
||||
<nav class="flex mb-5" aria-label="Breadcrumb">
|
||||
<ol class="inline-flex items-center space-x-1 text-sm font-medium md:space-x-2">
|
||||
<li class="inline-flex items-center">
|
||||
<a href="/" class="inline-flex items-center text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-white">
|
||||
<svg class="w-5 h-5 mr-2.5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg>
|
||||
Home
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
|
||||
<span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Secrets</span>
|
||||
</svg>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Secrets</h1>
|
||||
</div>
|
||||
|
||||
<div class="items-center justify-between block sm:flex">
|
||||
<div class="flex items-center mb-4 sm:mb-0">
|
||||
<label for="secret-search" class="sr-only">Search</label>
|
||||
<div class="relative w-48 mt-1 sm:w-64 xl:w-96">
|
||||
<input type="search" name="query" id="secret-search" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" placeholder="Search for secrets" hx-post="/secrets/query" hx-trigger="keyup changed delay:500ms, query" hx-target="#secretsContent">
|
||||
</div>
|
||||
</div>
|
||||
<button id="createSecretButton" class="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800" type="button" data-drawer-target="drawer-create-secret-default" data-drawer-show="drawer-create-secret-default" aria-controls="drawer-create-secret-default" data-drawer-placement="right">
|
||||
Add new secret
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div id="secretsContent">
|
||||
{% include '/secrets/inner.html.j2' %}
|
||||
</div>
|
||||
|
||||
{% include '/secrets/drawer_secret_create.html.j2' %}
|
||||
|
||||
{% endblock %}
|
||||
@ -0,0 +1,59 @@
|
||||
<div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="overflow-x-auto">
|
||||
<div class="inline-block min-w-full align-middle">
|
||||
<div class="overflow-hidden shadow">
|
||||
<table class="min-w-full divide-y divide-gray-200 table-fixed dark:divide-gray-600">
|
||||
<thead class="bg-gray-100 dark:bg-gray-700">
|
||||
<tr>
|
||||
|
||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
||||
Clients associated
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700">
|
||||
{% for secret in secrets %}
|
||||
{% include '/secrets/secret.html.j2'%}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sticky bottom-0 right-0 items-center w-full p-4 bg-white border-t border-gray-200 sm:flex sm:justify-between dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="flex items-center mb-4 sm:mb-0">
|
||||
<a href="#" class="inline-flex justify-center p-1 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"></path></svg>
|
||||
</a>
|
||||
<a href="#" class="inline-flex justify-center p-1 mr-2 text-gray-500 rounded cursor-pointer hover:text-gray-900 hover:bg-gray-100 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
|
||||
</a>
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">Showing <span class="font-semibold text-gray-900 dark:text-white">1-20</span> of <span class="font-semibold text-gray-900 dark:text-white">2290</span></span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<a href="#" class="inline-flex items-center justify-center flex-1 px-3 py-2 text-sm font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
|
||||
<svg class="w-5 h-5 mr-1 -ml-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"></path></svg>
|
||||
Previous
|
||||
</a>
|
||||
<a href="#" class="inline-flex items-center justify-center flex-1 px-3 py-2 text-sm font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
|
||||
Next
|
||||
<svg class="w-5 h-5 ml-1 -mr-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% for secret in secrets %}
|
||||
{% include '/secrets/modal_client_secret.html.j2' %}
|
||||
{% endfor %}
|
||||
@ -0,0 +1,119 @@
|
||||
<div
|
||||
id="client-secret-modal-{{ secret.name }}"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full"
|
||||
>
|
||||
<div class="relative p-4 w-full max-w-md max-h-full">
|
||||
<div class="relative bg-white rounded-lg shadow-sm dark:bg-gray-700">
|
||||
<div
|
||||
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600 border-gray-200"
|
||||
>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Edit Client Access
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
data-modal-hide="client-secret-modal-{{ secret.name }}"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 14 14"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 md:p-5">
|
||||
{% if secret.clients %}
|
||||
<div class="space-y-4">
|
||||
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Existing clients with access
|
||||
</h3>
|
||||
|
||||
{% for client in secret.clients %}
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-1 me-2 text-sm font-medium text-red-800 bg-red-100 rounded-sm dark:bg-red-900 dark:text-red-300"
|
||||
>{{ client.name }}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center p-1 ms-2 text-sm text-gray-400 bg-transparent rounded-xs hover:bg-gray-200 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-gray-300"
|
||||
aria-label="Remove"
|
||||
hx-delete="/secrets/{{ secret.name }}/clients/{{ client.id }}"
|
||||
hx-target="#secretsContent"
|
||||
hx-confirm="Remove client {{ client.name }} from secret {{secret.name}}?"
|
||||
>
|
||||
<svg
|
||||
class="w-2 h-2"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 14 14"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Remove badge</span>
|
||||
</button>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form
|
||||
class="space-y-4"
|
||||
hx-post="/secrets/{{ secret.name }}/clients/"
|
||||
hx-target="#secretsContent"
|
||||
>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Give client access
|
||||
</h3>
|
||||
<label
|
||||
for="client"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>Client
|
||||
</label>
|
||||
<select
|
||||
name="client"
|
||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
||||
>
|
||||
<option selected="selected">
|
||||
Select clients to assign the secret to
|
||||
</option>
|
||||
{% for client in clients %}
|
||||
{% if client.id|string not in secret.clients|map(attribute='id')|list %}
|
||||
<option value="{{ client.id }}">{{ client.name }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||
>
|
||||
Give Access
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,71 @@
|
||||
<tr
|
||||
class="hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
id="secret-{{ secret.id }}"
|
||||
>
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
||||
>
|
||||
{{ secret.name }}
|
||||
</td>
|
||||
<td
|
||||
class="max-w-sm p-4 overflow-hidden text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400"
|
||||
>
|
||||
{% if secret.clients %}
|
||||
{% for client in secret.clients %}
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-sm me-2 dark:bg-gray-700 dark:text-gray-400 border border-gray-500 ">
|
||||
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd" d="M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm-2 9a4 4 0 0 0-4 4v1a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-1a4 4 0 0 0-4-4h-4Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{{ client.name }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="italic font-small">No clients</p>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4 space-x-2 whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
data-modal-target="client-secret-modal-{{secret.name}}" data-modal-toggle="client-secret-modal-{{ secret.name }}"
|
||||
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"
|
||||
></path>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
Manage Client Access
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-red-700 rounded-lg hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900"
|
||||
hx-delete="/secrets/{{ secret.name }}"
|
||||
hx-confirm="Are you sure you want to delete the secret {{ secret.name }}?"
|
||||
hx-target="#secretsContent"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
Delete item
|
||||
</button>
|
||||
</td>
|
||||
@ -0,0 +1,25 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<title>{{ page_title }}</title>
|
||||
<meta name="description" content="{{ page_description }}" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('static', path='css/main.css') }}"
|
||||
type="text/css"
|
||||
/>
|
||||
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,95 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<title>{{ page_title }}</title>
|
||||
<meta name="description" content="{{ page_description }}" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('static', path='css/main.css') }}"
|
||||
type="text/css"
|
||||
/>
|
||||
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="bg-white border-gray-200 dark:bg-gray-900">
|
||||
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
|
||||
<div class="flex items-center md:order-2 space-x-3 md:space-x-0 rtl:space-x-reverse">
|
||||
<button type="button" class="flex text-sm bg-gray-800 rounded-full md:me-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600" id="user-menu-button" aria-expanded="false" data-dropdown-toggle="user-dropdown" data-dropdown-placement="bottom">
|
||||
<span class="sr-only">Open user menu</span>
|
||||
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="50" cy="50" r="50" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow-sm dark:bg-gray-700 dark:divide-gray-600" id="user-dropdown">
|
||||
<div class="px-4 py-3">
|
||||
<span class="block text-sm text-gray-900 dark:text-white">{{ user }}</span>
|
||||
</div>
|
||||
<ul class="py-2" aria-labelledby="user-menu-button">
|
||||
<li>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Change Password</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Log out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<button data-drawer-target="default-sidebar" data-drawer-toggle="default-sidebar" aria-controls="default-sidebar" type="button" class="inline-flex items-center p-2 mt-2 ms-3 text-sm text-gray-500 rounded-lg sm:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600">
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<svg class="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path clip-rule="evenodd" fill-rule="evenodd" d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
||||
<aside id="default-sidebar" class="fixed top-0 left-0 z-40 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0" aria-label="Sidebar">
|
||||
<div class="h-full px-3 py-4 overflow-y-auto bg-gray-50 dark:bg-gray-800">
|
||||
<a href="/" class="flex items-center ps-2.5 mb-5">
|
||||
<img src="{{ url_for('static', path='logo.svg') }}" class="h-6 me-3 sm:h-7" alt="Sshecret Logo" />
|
||||
<span class="self-center text-xl font-semibold whitespace-nowrap dark:text-white">Sshecret</span>
|
||||
</a>
|
||||
<ul class="space-y-2 font-medium">
|
||||
<li>
|
||||
<a href="#" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||
<svg class="shrink-0 w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1M5 12h14M5 12a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1m-2 3h.01M14 15h.01M17 9h.01M14 9h.01"/>
|
||||
</svg>
|
||||
|
||||
<span class="ms-3">Clients</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
||||
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9V4a1 1 0 0 0-1-1H8.914a1 1 0 0 0-.707.293L4.293 7.207A1 1 0 0 0 4 7.914V20a1 1 0 0 0 1 1h6M9 3v4a1 1 0 0 1-1 1H4m11 13a11.426 11.426 0 0 1-3.637-3.99A11.139 11.139 0 0 1 10 11.833L15 10l5 1.833a11.137 11.137 0 0 1-1.363 5.176A11.425 11.425 0 0 1 15.001 21Z"/>
|
||||
</svg>
|
||||
<span class="ms-3">Secrets</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="p-4 sm:ml-64">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,128 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<title>{{ page_title }}</title>
|
||||
<meta name="description" content="{{ page_description }}" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('static', path='css/main.css') }}"
|
||||
type="text/css"
|
||||
/>
|
||||
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
class="fixed left-0 top-0 w-64 h-full bg-[#f8f4f3] p-4 z-50 sidebar-menu transition-transform"
|
||||
>
|
||||
<a href="#" class="flex items-center pb-4 border-b border-b-gray-800">
|
||||
<h2 class="font-bold text-2xl">
|
||||
SSHecret
|
||||
<span class="bg-[#f84525] text-white px-2 rounded-md">Admin</span>
|
||||
</h2>
|
||||
</a>
|
||||
<!-- MENU -->
|
||||
<ul class="mt4">
|
||||
<span class="text-gray-400 font-bold">Admin</span>
|
||||
<li class="mb-1 group">
|
||||
<a
|
||||
href="/clients"
|
||||
class="flex font-semibold items-center py-2 px-4 text-gray-900 hover:bg-gray-950 hover:text-gray-100 rounded-md group-[.active]:bg-gray-800 group-[.active]:text-white group-[.selected]:bg-gray-950 group-[.selected]:text-gray-100"
|
||||
>
|
||||
<i class="ri-server-line mr-3 text-lg"></i>
|
||||
<span class="text-sm">Clients</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="mb-1 group">
|
||||
<a
|
||||
href="/secrets"
|
||||
class="flex font-semibold items-center py-2 px-4 text-gray-900 hover:bg-gray-950 hover:text-gray-100 rounded-md group-[.active]:bg-gray-800 group-[.active]:text-white group-[.selected]:bg-gray-950 group-[.selected]:text-gray-100"
|
||||
>
|
||||
<i class="ri-safe-2-line mr-3 text-lg"></i>
|
||||
<span class="text-sm">Secrets</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
class="fixed top-0 left-0 w-full h-full bg-black/50 z-40 md:hidden sidebar-overlay"
|
||||
></div>
|
||||
<main
|
||||
class="w-full md:w-[calc(100%-256px)] md:ml-64 bg-gray-200 min-h-screen transition-all main"
|
||||
>
|
||||
<!-- navbar -->
|
||||
<div
|
||||
class="py-2 px-6 bg-[#f8f4f3] flex items-center shadow-md shadow-black/5 sticky top-0 left-0 z-30"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-lg text-gray-900 font-semibold sidebar-toggle"
|
||||
>
|
||||
<i class="ri-menu-line"></i>
|
||||
</button>
|
||||
<ul class="ml-auto flex items-center">
|
||||
<li class="dropdown ml-3">
|
||||
<button type="button" class="dropdown-toggle flex items-center">
|
||||
<div class="flex-shrink-0 w-10 h-10 relative">
|
||||
<div
|
||||
class="p-1 bg-white rounded-full focus:outline-none focus:ring"
|
||||
>
|
||||
<div
|
||||
class="top-0 left-7 absolute w-3 h-3 bg-lime-400 border-2 border-white rounded-full animate-ping"
|
||||
></div>
|
||||
<div
|
||||
class="top-0 left-7 absolute w-3 h-3 bg-lime-500 border-2 border-white rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 md:block text-left">
|
||||
<h2 class="text-sm font-semibold text-gray-800">
|
||||
{{ user.username }}
|
||||
</h2>
|
||||
</div>
|
||||
</button>
|
||||
<ul
|
||||
class="dropdown-menu shadow-md shadow-black/5 z-30 hidden py-1.5 rounded-md bg-white border border-gray-100 w-full max-w-[140px]"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center text-[13px] py-1.5 px-4 text-gray-600 hover:text-[#f84525] hover:bg-gray-50"
|
||||
>Profile</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
class="flex items-center text-[13px] py-1.5 px-4 text-gray-600 hover:text-[#f84525] hover:bg-gray-50"
|
||||
>Settings</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<form method="POST" action="">
|
||||
<a
|
||||
role="menuitem"
|
||||
class="flex items-center text-[13px] py-1.5 px-4 text-gray-600 hover:text-[#f84525] hover:bg-gray-50 cursor-pointer"
|
||||
onclick="event.preventDefault();
|
||||
this.closest('form').submit();"
|
||||
>
|
||||
Log Out
|
||||
</a>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-6">{% block content %}{% endblock %}</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,6 @@
|
||||
{% extends "/shared/_base.html" %} {% block content %}
|
||||
|
||||
<h1>Hooray!</h1>
|
||||
<p>It worked!</p>
|
||||
<p>Welcome, {{ user.username }}</p>
|
||||
{% endblock %}
|
||||
45
packages/sshecret-admin/src/sshecret_admin/testing.py
Normal file
45
packages/sshecret-admin/src/sshecret_admin/testing.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""Testing helper functions."""
|
||||
|
||||
import os
|
||||
|
||||
import bcrypt
|
||||
|
||||
from sqlalchemy import Engine
|
||||
from sqlmodel import Session, select
|
||||
from .auth_models import User
|
||||
|
||||
|
||||
def get_test_user_details() -> tuple[str, str]:
|
||||
"""Resolve testing user."""
|
||||
test_user = os.getenv("SSHECRET_TEST_USERNAME") or "test"
|
||||
test_password = os.getenv("SSHECRET_TEST_PASSWORD") or "test"
|
||||
if test_user and test_password:
|
||||
return (test_user, test_password)
|
||||
|
||||
raise RuntimeError(
|
||||
"Error: No testing username and password registered in environment."
|
||||
)
|
||||
|
||||
|
||||
def is_testing_mode() -> bool:
|
||||
"""Check if we're running in test mode.
|
||||
|
||||
We will determine this by looking for the environment variable SSHECRET_TEST_MODE=1
|
||||
"""
|
||||
if os.environ.get("PYTEST_VERSION") is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def create_test_user(session: Session, username: str, password: str) -> User:
|
||||
"""Create test user.
|
||||
|
||||
We create a user with whatever username and password is supplied.
|
||||
"""
|
||||
salt = bcrypt.gensalt()
|
||||
hashed_password = bcrypt.hashpw(password.encode(), salt)
|
||||
user = User(username=username, hashed_password=hashed_password.decode())
|
||||
session.add(user)
|
||||
session.commit()
|
||||
return user
|
||||
|
||||
21
packages/sshecret-admin/src/sshecret_admin/types.py
Normal file
21
packages/sshecret-admin/src/sshecret_admin/types.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""Common type definitions."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable, Generator, Awaitable
|
||||
|
||||
from fastapi import Request
|
||||
from sqlmodel import Session
|
||||
from sshecret_admin.admin_backend import AdminBackend
|
||||
from sshecret_admin.auth_models import User
|
||||
from sshecret.backend import SshecretBackend
|
||||
from . import keepass
|
||||
|
||||
|
||||
DBSessionDep = Callable[[], Generator[Session, None, None]]
|
||||
|
||||
BackendDep = Callable[[], AsyncGenerator[SshecretBackend, None]]
|
||||
|
||||
PasswdCtxDep = Callable[[DBSessionDep], AsyncGenerator[keepass.PasswordContext, None]]
|
||||
|
||||
AdminDep = Callable[[Session], AsyncGenerator[AdminBackend, None]]
|
||||
|
||||
UserTokenDep = Callable[[Request, Session], Awaitable[User]]
|
||||
@ -1,9 +1,17 @@
|
||||
"""Models for the API."""
|
||||
|
||||
from typing import Annotated
|
||||
from pydantic import AfterValidator, BaseModel, Field, IPvAnyAddress, IPvAnyNetwork
|
||||
from .crypto import validate_public_key
|
||||
from .backend import Client
|
||||
import secrets
|
||||
from typing import Annotated, Literal, Self, Union
|
||||
from pydantic import (
|
||||
AfterValidator,
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
IPvAnyAddress,
|
||||
IPvAnyNetwork,
|
||||
model_validator,
|
||||
)
|
||||
from sshecret.crypto import validate_public_key
|
||||
|
||||
|
||||
def public_key_validator(value: str) -> str:
|
||||
@ -12,6 +20,7 @@ def public_key_validator(value: str) -> str:
|
||||
return value
|
||||
raise ValueError("Error: Public key must be a valid RSA public key.")
|
||||
|
||||
|
||||
class SecretListView(BaseModel):
|
||||
"""Model containing a list of all available secrets."""
|
||||
|
||||
@ -53,3 +62,52 @@ class ClientCreate(BaseModel):
|
||||
name: str
|
||||
public_key: Annotated[str, AfterValidator(public_key_validator)]
|
||||
sources: list[IPvAnyAddress | IPvAnyNetwork] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AutoGenerateOpts(BaseModel):
|
||||
"""Option to auto-generate a password."""
|
||||
|
||||
auto_generate: Literal[True]
|
||||
length: int = 32
|
||||
|
||||
|
||||
class SecretUpdate(BaseModel):
|
||||
"""Model to update a secret."""
|
||||
|
||||
value: str | AutoGenerateOpts = Field(
|
||||
description="Secret as string value or auto-generated with optional length",
|
||||
examples=["MySecretString", {"auto_generate": True, "length": 32}]
|
||||
)
|
||||
|
||||
def get_secret(self) -> str:
|
||||
"""Get secret.
|
||||
|
||||
This returns the specified one, or generates one according to auto-generation.
|
||||
"""
|
||||
if isinstance(self.value, str):
|
||||
return self.value
|
||||
secret = secrets.token_urlsafe(self.value.length)
|
||||
return secret
|
||||
|
||||
|
||||
class SecretCreate(SecretUpdate):
|
||||
"""Model to create a secret."""
|
||||
|
||||
name: str
|
||||
clients: list[str] | None = Field(default=None, description="Assign the secret to a list of clients.")
|
||||
|
||||
model_config: ConfigDict = ConfigDict(
|
||||
json_schema_extra={
|
||||
"examples": [
|
||||
{
|
||||
"name": "MySecret",
|
||||
"clients": ["client-1", "client-2"],
|
||||
"value": { "auto_generate": True, "length": 32 }
|
||||
},
|
||||
{
|
||||
"name": "MySecret",
|
||||
"value": "mysecretstring",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
from .audit import create_audit_view
|
||||
from .clients import create_client_view
|
||||
from .secrets import create_secrets_view
|
||||
|
||||
__all__ = ["create_audit_view", "create_client_view", "create_secrets_view"]
|
||||
113
packages/sshecret-admin/src/sshecret_admin/views/audit.py
Normal file
113
packages/sshecret-admin/src/sshecret_admin/views/audit.py
Normal file
@ -0,0 +1,113 @@
|
||||
"""Audit view."""
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
import math
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
||||
from pydantic import BaseModel
|
||||
|
||||
from sshecret_admin.admin_backend import AdminBackend
|
||||
from sshecret_admin.types import UserTokenDep, AdminDep
|
||||
from sshecret_admin.auth_models import User
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
class PagingInfo(BaseModel):
|
||||
|
||||
page: int
|
||||
limit: int
|
||||
total: int
|
||||
offset: int = 0
|
||||
|
||||
@property
|
||||
def first(self) -> int:
|
||||
"""The first result number."""
|
||||
return self.offset + 1
|
||||
|
||||
@property
|
||||
def last(self) -> int:
|
||||
"""Return the last result number."""
|
||||
return self.offset + self.limit
|
||||
|
||||
@property
|
||||
def total_pages(self) -> int:
|
||||
"""Return total pages."""
|
||||
return math.ceil(self.total / self.limit)
|
||||
|
||||
def create_audit_view(
|
||||
templates: Jinja2Blocks,
|
||||
get_current_user_from_token: UserTokenDep,
|
||||
get_admin_backend: AdminDep,
|
||||
) -> APIRouter:
|
||||
"""Create client view."""
|
||||
|
||||
app = APIRouter()
|
||||
|
||||
async def resolve_audit_entries(
|
||||
request: Request,
|
||||
current_user: User,
|
||||
admin: AdminBackend,
|
||||
page: int
|
||||
) -> Response:
|
||||
"""Resolve audit entries."""
|
||||
LOG.info("Page: %r", page)
|
||||
total_messages = await admin.get_audit_log_count()
|
||||
per_page = 20
|
||||
offset = 0
|
||||
if page > 1:
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
entries = await admin.get_audit_log(offset=offset, limit=per_page)
|
||||
LOG.info("Entries: %r", entries)
|
||||
page_info = PagingInfo(page=page, limit=per_page, total=total_messages, offset=offset)
|
||||
if request.headers.get("HX-Request"):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"audit/inner.html.j2",
|
||||
{
|
||||
"entries": entries,
|
||||
"page_info": page_info,
|
||||
}
|
||||
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"audit/index.html.j2",
|
||||
{
|
||||
"page_title": "Audit",
|
||||
"entries": entries,
|
||||
"user": current_user.username,
|
||||
"page_info": page_info,
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/audit/")
|
||||
async def get_audit_entries(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
):
|
||||
"""Get audit entries."""
|
||||
return await resolve_audit_entries(request, current_user, admin, 1)
|
||||
|
||||
@app.get("/audit/page/{page}")
|
||||
async def get_audit_entries_page(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
page: int,
|
||||
):
|
||||
"""Get audit entries."""
|
||||
LOG.info("Get audit entries page: %r", page)
|
||||
return await resolve_audit_entries(request, current_user, admin, page)
|
||||
|
||||
|
||||
|
||||
# --------------#
|
||||
# END OF ROUTES #
|
||||
# --------------#
|
||||
return app
|
||||
229
packages/sshecret-admin/src/sshecret_admin/views/clients.py
Normal file
229
packages/sshecret-admin/src/sshecret_admin/views/clients.py
Normal file
@ -0,0 +1,229 @@
|
||||
"""Client views."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Request, Form
|
||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
||||
|
||||
from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork
|
||||
from sshecret_admin.admin_backend import AdminBackend
|
||||
from sshecret.backend import ClientFilter
|
||||
from sshecret.backend.models import FilterType
|
||||
from sshecret.crypto import validate_public_key
|
||||
from sshecret_admin.types import UserTokenDep, AdminDep
|
||||
from sshecret_admin.auth_models import User
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClientUpdate(BaseModel):
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str
|
||||
public_key: str
|
||||
sources: str | None = None
|
||||
|
||||
|
||||
class ClientCreate(BaseModel):
|
||||
|
||||
name: str
|
||||
public_key: str
|
||||
description: str | None
|
||||
sources: str | None
|
||||
|
||||
|
||||
def create_client_view(
|
||||
templates: Jinja2Blocks,
|
||||
get_current_user_from_token: UserTokenDep,
|
||||
get_admin_backend: AdminDep,
|
||||
) -> APIRouter:
|
||||
"""Create client view."""
|
||||
|
||||
app = APIRouter()
|
||||
|
||||
@app.get("/clients")
|
||||
async def get_clients(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
):
|
||||
"""Get clients."""
|
||||
clients = await admin.get_clients()
|
||||
LOG.info("Clients %r", clients)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"clients/index.html.j2",
|
||||
{
|
||||
"page_title": "Clients",
|
||||
"clients": clients,
|
||||
"user": current_user.username,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/clients/query")
|
||||
async def query_clients(
|
||||
request: Request,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
query: Annotated[str, Form()],
|
||||
):
|
||||
"""Query for a client."""
|
||||
query_filter: ClientFilter | None = None
|
||||
if query:
|
||||
name = f"%{query}%"
|
||||
query_filter = ClientFilter(name=name, filter_name=FilterType.LIKE)
|
||||
clients = await admin.get_clients(query_filter)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"clients/inner.html.j2",
|
||||
{
|
||||
"clients": clients,
|
||||
},
|
||||
)
|
||||
|
||||
@app.put("/clients/{id}")
|
||||
async def update_client(
|
||||
request: Request,
|
||||
id: str,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
client: Annotated[ClientUpdate, Form()],
|
||||
):
|
||||
"""Update a client."""
|
||||
original_client = await admin.get_client(id)
|
||||
if not original_client:
|
||||
return templates.TemplateResponse(
|
||||
request, "fragments/error.html", {"message": "Client not found"}
|
||||
)
|
||||
|
||||
sources: list[IPvAnyAddress | IPvAnyNetwork] = []
|
||||
if client.sources:
|
||||
source_str = client.sources.split(",")
|
||||
for source in source_str:
|
||||
if "/" in source:
|
||||
sources.append(ipaddress.ip_network(source.strip()))
|
||||
else:
|
||||
sources.append(ipaddress.ip_address(source.strip()))
|
||||
client_fields = client.model_dump(exclude_unset=True)
|
||||
|
||||
del client_fields["sources"]
|
||||
if sources:
|
||||
client_fields["policies"] = sources
|
||||
|
||||
LOG.info("Fields: %r", client_fields)
|
||||
updated_client = original_client.model_copy(update=client_fields)
|
||||
|
||||
await admin.update_client(updated_client)
|
||||
|
||||
clients = await admin.get_clients()
|
||||
headers = {"Hx-Refresh": "true"}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"clients/inner.html.j2",
|
||||
{
|
||||
"clients": clients,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@app.delete("/clients/{id}")
|
||||
async def delete_client(
|
||||
request: Request,
|
||||
id: str,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
):
|
||||
"""Delete a client."""
|
||||
await admin.delete_client(id)
|
||||
clients = await admin.get_clients()
|
||||
headers = {"Hx-Refresh": "true"}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"clients/inner.html.j2",
|
||||
{
|
||||
"clients": clients,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@app.post("/clients/")
|
||||
async def create_client(
|
||||
request: Request,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
client: Annotated[ClientCreate, Form()],
|
||||
):
|
||||
"""Create client."""
|
||||
sources: list[str] | None = None
|
||||
if client.sources:
|
||||
sources = [source.strip() for source in client.sources.split(",")]
|
||||
await admin.create_client(
|
||||
client.name, client.public_key.rstrip(), client.description, sources
|
||||
)
|
||||
clients = await admin.get_clients()
|
||||
headers = {"Hx-Refresh": "true"}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"clients/inner.html.j2",
|
||||
{
|
||||
"clients": clients,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@app.post("/clients/validate/source")
|
||||
async def validate_client_source(
|
||||
request: Request,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
sources: Annotated[str, Form()],
|
||||
):
|
||||
"""Validate source."""
|
||||
source_str = sources.split(",")
|
||||
for source in source_str:
|
||||
if "/" in source:
|
||||
try:
|
||||
_network = ipaddress.ip_network(source.strip())
|
||||
except Exception:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"/clients/field_invalid.html.j2",
|
||||
{"explanation": f"Invalid network {source.strip()}"},
|
||||
)
|
||||
else:
|
||||
try:
|
||||
_address = ipaddress.ip_address(source.strip())
|
||||
except Exception:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"/clients/field_invalid.html.j2",
|
||||
{"explanation": f"Invalid address {source.strip()}"},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"/clients/field_valid.html.j2",
|
||||
)
|
||||
|
||||
@app.post("/clients/validate/public_key")
|
||||
async def validate_client_public_key(
|
||||
request: Request,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
public_key: Annotated[str, Form()],
|
||||
):
|
||||
"""Validate source."""
|
||||
if validate_public_key(public_key.rstrip()):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"/clients/field_valid.html.j2",
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"/clients/field_invalid.html.j2",
|
||||
{"explanation": "Invalid value. Not a valid SSH RSA Public Key."},
|
||||
)
|
||||
|
||||
return app
|
||||
178
packages/sshecret-admin/src/sshecret_admin/views/secrets.py
Normal file
178
packages/sshecret-admin/src/sshecret_admin/views/secrets.py
Normal file
@ -0,0 +1,178 @@
|
||||
"""Secrets view."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
import logging
|
||||
import secrets as pysecrets
|
||||
from typing import Annotated, Any
|
||||
from fastapi import APIRouter, Depends, Request, Form
|
||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
||||
|
||||
from pydantic import BaseModel, BeforeValidator, Field
|
||||
from sshecret_admin.admin_backend import AdminBackend
|
||||
from sshecret_admin.types import UserTokenDep, AdminDep
|
||||
from sshecret_admin.auth_models import User
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def split_clients(clients: Any) -> Any:
|
||||
"""Split clients."""
|
||||
if isinstance(clients, list):
|
||||
return clients
|
||||
if not isinstance(clients, str):
|
||||
raise ValueError("Invalid type for clients.")
|
||||
if not clients:
|
||||
return []
|
||||
return [client.rstrip() for client in clients.split(",")]
|
||||
|
||||
|
||||
def handle_select_bool(value: Any) -> Any:
|
||||
"""Handle boolean from select."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value == "on":
|
||||
return True
|
||||
if value == "off":
|
||||
return False
|
||||
|
||||
|
||||
class CreateSecret(BaseModel):
|
||||
"""Create secret model."""
|
||||
|
||||
name: str
|
||||
value: str | None = None
|
||||
auto_generate: Annotated[bool, BeforeValidator(handle_select_bool)] = False
|
||||
clients: Annotated[list[str], BeforeValidator(split_clients)] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
def create_secrets_view(
|
||||
templates: Jinja2Blocks,
|
||||
get_current_user_from_token: UserTokenDep,
|
||||
get_admin_backend: AdminDep,
|
||||
) -> APIRouter:
|
||||
"""Create secrets view."""
|
||||
|
||||
app = APIRouter()
|
||||
|
||||
@app.get("/secrets/")
|
||||
async def get_secrets(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
):
|
||||
"""Get secrets index page."""
|
||||
secrets = await admin.get_detailed_secrets()
|
||||
clients = await admin.get_clients()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/index.html.j2",
|
||||
{
|
||||
"page_title": "Secrets",
|
||||
"secrets": secrets,
|
||||
"user": current_user.username,
|
||||
"clients": clients,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/secrets/")
|
||||
async def add_secret(
|
||||
request: Request,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
secret: Annotated[CreateSecret, Form()],
|
||||
):
|
||||
"""Add secret."""
|
||||
LOG.info("secret: %s", secret.model_dump_json(indent=2))
|
||||
|
||||
clients = await admin.get_clients()
|
||||
if secret.value:
|
||||
value = secret.value
|
||||
else:
|
||||
value = pysecrets.token_urlsafe(32)
|
||||
|
||||
await admin.add_secret(secret.name, value, secret.clients)
|
||||
secrets = await admin.get_detailed_secrets()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/inner.html.j2",
|
||||
{
|
||||
"secrets": secrets,
|
||||
"clients": clients,
|
||||
},
|
||||
)
|
||||
|
||||
@app.delete("/secrets/{name}/clients/{id}")
|
||||
async def remove_client_secret_access(
|
||||
request: Request,
|
||||
name: str,
|
||||
id: str,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
):
|
||||
"""Remove a client's access to a secret."""
|
||||
await admin.delete_client_secret(id, name)
|
||||
clients = await admin.get_clients()
|
||||
|
||||
secrets = await admin.get_detailed_secrets()
|
||||
headers = {"Hx-Refresh": "true"}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/inner.html.j2",
|
||||
{"clients": clients, "secret": secrets},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@app.post("/secrets/{name}/clients/")
|
||||
async def add_secret_to_client(
|
||||
request: Request,
|
||||
name: str,
|
||||
client: Annotated[str, Form()],
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
):
|
||||
"""Add a secret to a client."""
|
||||
await admin.create_client_secret(client, name)
|
||||
clients = await admin.get_clients()
|
||||
secrets = await admin.get_detailed_secrets()
|
||||
headers = {"Hx-Refresh": "true"}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/inner.html.j2",
|
||||
{
|
||||
"clients": clients,
|
||||
"secrets": secrets,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@app.delete("/secrets/{name}")
|
||||
async def delete_secret(
|
||||
request: Request,
|
||||
name: str,
|
||||
_current_user: Annotated[User, Depends(get_current_user_from_token)],
|
||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
||||
):
|
||||
"""Delete a secret."""
|
||||
await admin.delete_secret(name)
|
||||
clients = await admin.get_clients()
|
||||
secrets = await admin.get_detailed_secrets()
|
||||
headers = {"Hx-Refresh": "true"}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/inner.html.j2",
|
||||
{
|
||||
"clients": clients,
|
||||
"secrets": secrets,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# --------------#
|
||||
# END OF ROUTES #
|
||||
# --------------#
|
||||
return app
|
||||
24
packages/sshecret-admin/static/index.html
Normal file
24
packages/sshecret-admin/static/index.html
Normal file
@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html class="no-js" lang="">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>Untitled</title>
|
||||
<meta name="description" content="" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<!-- Place favicon.ico in the root directory -->
|
||||
</head>
|
||||
<body>
|
||||
<!--[if lt IE 8]>
|
||||
<p class="browserupgrade">
|
||||
You are using an <strong>outdated</strong> browser. Please
|
||||
<a href="http://browsehappy.com/">upgrade your browser</a> to improve
|
||||
your experience.
|
||||
</p>
|
||||
<![endif]-->
|
||||
|
||||
<p>I am outside of the package</p>
|
||||
</body>
|
||||
</html>
|
||||
93
packages/sshecret-admin/tailwind.config.js
Normal file
93
packages/sshecret-admin/tailwind.config.js
Normal file
@ -0,0 +1,93 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/sshecret_admin/templates/**/*.html",
|
||||
"./src/sshecret_admin/static/**/*.js",
|
||||
],
|
||||
safelist: [
|
||||
"w-64",
|
||||
"w-1/2",
|
||||
"rounded-l-lg",
|
||||
"rounded-r-lg",
|
||||
"bg-gray-200",
|
||||
"grid-cols-4",
|
||||
"grid-cols-7",
|
||||
"h-6",
|
||||
"leading-6",
|
||||
"h-9",
|
||||
"leading-9",
|
||||
"shadow-lg",
|
||||
"bg-opacity-50",
|
||||
"dark:bg-opacity-80",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
"50": "#eff6ff",
|
||||
"100": "#dbeafe",
|
||||
"200": "#bfdbfe",
|
||||
"300": "#93c5fd",
|
||||
"400": "#60a5fa",
|
||||
"500": "#3b82f6",
|
||||
"600": "#2563eb",
|
||||
"700": "#1d4ed8",
|
||||
"800": "#1e40af",
|
||||
"900": "#1e3a8a",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"Inter",
|
||||
"ui-sans-serif",
|
||||
"system-ui",
|
||||
"-apple-system",
|
||||
"system-ui",
|
||||
"Segoe UI",
|
||||
"Roboto",
|
||||
"Helvetica Neue",
|
||||
"Arial",
|
||||
"Noto Sans",
|
||||
"sans-serif",
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol",
|
||||
"Noto Color Emoji",
|
||||
],
|
||||
body: [
|
||||
"Inter",
|
||||
"ui-sans-serif",
|
||||
"system-ui",
|
||||
"-apple-system",
|
||||
"system-ui",
|
||||
"Segoe UI",
|
||||
"Roboto",
|
||||
"Helvetica Neue",
|
||||
"Arial",
|
||||
"Noto Sans",
|
||||
"sans-serif",
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol",
|
||||
"Noto Color Emoji",
|
||||
],
|
||||
mono: [
|
||||
"ui-monospace",
|
||||
"SFMono-Regular",
|
||||
"Menlo",
|
||||
"Monaco",
|
||||
"Consolas",
|
||||
"Liberation Mono",
|
||||
"Courier New",
|
||||
"monospace",
|
||||
],
|
||||
},
|
||||
transitionProperty: {
|
||||
width: "width",
|
||||
},
|
||||
textDecoration: ["active"],
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [],
|
||||
};
|
||||
Reference in New Issue
Block a user