Compare commits
30 Commits
4520e9a781
...
f312edabd7
| Author | SHA1 | Date | |
|---|---|---|---|
| f312edabd7 | |||
| 3f6b49a69b | |||
| 1156bc315e | |||
| 1362d0a289 | |||
| b35a777a89 | |||
| 73c7abeb65 | |||
| d3a0f698ac | |||
| 0059480363 | |||
| 8beefdf82f | |||
| ef8b50e302 | |||
| 33c1e7278b | |||
| f0c729cba7 | |||
| f8eac2b09c | |||
| f518723a0e | |||
| 37f381c884 | |||
| 45ae0929e6 | |||
| 3efc4d7fa5 | |||
| 412a84150e | |||
| 6a5149fd4c | |||
| 5ac4c987d3 | |||
| 736dad748b | |||
| 746f809d28 | |||
| 6faed0dbd4 | |||
| 880d556542 | |||
| 3ef659be61 | |||
| c7ecc3f365 | |||
| 82ec7fabb4 | |||
| 5985a726e3 | |||
| 4a5874d4f8 | |||
| 1cde31a023 |
@ -12,9 +12,17 @@ RUN uv build --package sshecret
|
||||
RUN uv build --package sshecret-admin
|
||||
|
||||
|
||||
FROM node:lts-alpine AS frontend-build
|
||||
WORKDIR /app
|
||||
COPY packages/sshecret-frontend/ .
|
||||
RUN npm install
|
||||
RUN npm build
|
||||
|
||||
|
||||
FROM python:3.13-slim-bookworm
|
||||
|
||||
COPY --from=builder --chown=app:app /build/dist /opt/sshecret
|
||||
COPY --from=frontend-build --chown=app:app /app/dist /opt/sshecret-frontend
|
||||
|
||||
COPY packages/sshecret-admin /opt/sshecret-admin
|
||||
COPY docker/admin.entrypoint.sh /entrypoint.sh
|
||||
|
||||
@ -9,6 +9,7 @@ fail() {
|
||||
|
||||
export SSHECRET_ADMIN_DATABASE="/opt/sshecret-admin/sshecret_admin.db"
|
||||
export SSHECRET_ADMIN_PASSWORD_MANAGER_DIRECTORY="/opt/sshecret-admin"
|
||||
export SSHECRET_ADMIN_FRONTEND_DIR="/opt/sshecret-frontend"
|
||||
|
||||
alembic upgrade head
|
||||
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy import Engine, engine_from_config, pool, create_engine
|
||||
|
||||
from alembic import context
|
||||
from sshecret_admin.auth.models import Base
|
||||
from sshecret_admin.core.settings import AdminServerSettings
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
@ -14,9 +14,30 @@ config = context.config
|
||||
|
||||
def get_database_url() -> str | None:
|
||||
"""Get database URL."""
|
||||
if db_file := os.getenv("SSHECRET_ADMIN_DATABASE"):
|
||||
return f"sqlite:///{db_file}"
|
||||
return config.get_main_option("sqlalchemy.url")
|
||||
try:
|
||||
settings = AdminServerSettings() # pyright: ignore[reportCallIssue]
|
||||
return str(settings.admin_db)
|
||||
except Exception:
|
||||
if db_file := os.getenv("SSHECRET_ADMIN_DATABASE"):
|
||||
return f"sqlite:///{db_file}"
|
||||
return config.get_main_option("sqlalchemy.url")
|
||||
|
||||
|
||||
def get_engine() -> Engine:
|
||||
"""Get engine."""
|
||||
try:
|
||||
settings = AdminServerSettings() # pyright: ignore[reportCallIssue]
|
||||
engine = create_engine(settings.admin_db)
|
||||
return engine
|
||||
except Exception:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
return connectable
|
||||
|
||||
|
||||
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
@ -68,12 +89,7 @@ def run_migrations_online() -> None:
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
connectable = get_engine()
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata, render_as_batch=True
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
"""Implement db structures for internal password manager
|
||||
|
||||
Revision ID: 84356d0ea85f
|
||||
Revises: 6c148590471f
|
||||
Create Date: 2025-06-21 07:21:02.257865
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '84356d0ea85f'
|
||||
down_revision: Union[str, None] = '6c148590471f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('groups',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('parent_id', sa.Uuid(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['groups.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('password_db', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('client_id', sa.Uuid(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('password_db', schema=None) as batch_op:
|
||||
batch_op.drop_column('client_id')
|
||||
|
||||
op.drop_table('groups')
|
||||
# ### end Alembic commands ###
|
||||
@ -0,0 +1,48 @@
|
||||
"""Implement managed secrets
|
||||
|
||||
Revision ID: c34707a1ea3a
|
||||
Revises: 84356d0ea85f
|
||||
Create Date: 2025-06-21 07:38:12.994535
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c34707a1ea3a'
|
||||
down_revision: Union[str, None] = '84356d0ea85f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('managed_secrets',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('is_deleted', sa.Boolean(), nullable=False),
|
||||
sa.Column('group_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
with op.batch_alter_table('groups', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('description', sa.String(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('groups', schema=None) as batch_op:
|
||||
batch_op.drop_column('description')
|
||||
|
||||
op.drop_table('managed_secrets')
|
||||
# ### end Alembic commands ###
|
||||
@ -0,0 +1,31 @@
|
||||
"""Audit API."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Query, Security
|
||||
|
||||
from sshecret_admin.core.dependencies import AdminDependencies
|
||||
from sshecret_admin.services import AdminBackend
|
||||
from sshecret_admin.services.models import AuditQueryFilter
|
||||
|
||||
from sshecret.backend.models import AuditInfo, AuditListResult
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
"""Create audit log API."""
|
||||
|
||||
app = APIRouter(dependencies=[Security(dependencies.get_current_active_user)])
|
||||
|
||||
@app.get("/audit/")
|
||||
async def get_audit_log(
|
||||
query_filter: Annotated[AuditQueryFilter, Query()],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> AuditListResult:
|
||||
"""Query audit log."""
|
||||
params = query_filter.model_dump(exclude_none=True, exclude_defaults=True)
|
||||
return await admin.get_audit_log_detailed(**params)
|
||||
|
||||
return app
|
||||
@ -1,39 +1,228 @@
|
||||
"""Authentication related endpoints factory."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
import os
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import Annotated, Literal
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
Form,
|
||||
HTTPException,
|
||||
Request,
|
||||
Security,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from sshecret_admin.auth import Token, authenticate_user, create_access_token
|
||||
from sshecret_admin.auth import (
|
||||
LocalUserInfo,
|
||||
Token,
|
||||
User,
|
||||
authenticate_user_async,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
)
|
||||
from sshecret_admin.auth.authentication import handle_oidc_claim, hash_password
|
||||
from sshecret_admin.auth.exceptions import AuthenticationFailedError
|
||||
from sshecret_admin.auth.models import AuthProvider, LoginInfo
|
||||
from sshecret_admin.auth.oidc import AdminOidc
|
||||
from sshecret_admin.core.dependencies import AdminDependencies
|
||||
from sshecret_admin.core.settings import AdminServerSettings
|
||||
from sshecret_admin.services import AdminBackend
|
||||
from sshecret_admin.services.models import UserPasswordChange
|
||||
|
||||
from sshecret.backend.models import Operation
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RefreshTokenForm(BaseModel):
|
||||
"""The refresh token form data."""
|
||||
|
||||
grant_type: Literal["refresh_token"]
|
||||
refresh_token: str
|
||||
|
||||
|
||||
def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
"""Create auth router."""
|
||||
app = APIRouter()
|
||||
|
||||
def get_oidc_client() -> AdminOidc:
|
||||
"""Get OIDC client dependency."""
|
||||
if not dependencies.settings.oidc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="OIDC authentication not available.",
|
||||
)
|
||||
oidc = AdminOidc(dependencies.settings.oidc)
|
||||
return oidc
|
||||
|
||||
@app.post("/token")
|
||||
async def login_for_access_token(
|
||||
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
) -> Token:
|
||||
"""Login user and generate token."""
|
||||
user = authenticate_user(session, form_data.username, form_data.password)
|
||||
user = await authenticate_user_async(
|
||||
session, form_data.username, form_data.password
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token_data: dict[str, str] = {"sub": user.username}
|
||||
access_token = create_access_token(
|
||||
dependencies.settings,
|
||||
data={"sub": user.username},
|
||||
data=token_data,
|
||||
)
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
refresh_token = create_refresh_token(dependencies.settings, data=token_data)
|
||||
|
||||
return Token(
|
||||
access_token=access_token, refresh_token=refresh_token, token_type="bearer"
|
||||
)
|
||||
|
||||
@app.post("/refresh")
|
||||
async def refresh_token(
|
||||
form_data: Annotated[RefreshTokenForm, Form()],
|
||||
) -> Token:
|
||||
"""Refresh access token."""
|
||||
LOG.info("Refresh token data: %r", form_data)
|
||||
claims = decode_token(dependencies.settings, form_data.refresh_token)
|
||||
if not claims:
|
||||
LOG.info("Could not decode claims")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token_data: dict[str, str] = {"sub": claims.sub}
|
||||
access_token = create_access_token(
|
||||
dependencies.settings,
|
||||
data=token_data,
|
||||
)
|
||||
refresh_token = create_refresh_token(dependencies.settings, data=token_data)
|
||||
|
||||
return Token(
|
||||
access_token=access_token, refresh_token=refresh_token, token_type="bearer"
|
||||
)
|
||||
|
||||
@app.post("/password")
|
||||
async def change_password(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Security(dependencies.get_current_active_user)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
|
||||
password_form: UserPasswordChange,
|
||||
) -> None:
|
||||
"""Change user password"""
|
||||
user = await authenticate_user_async(
|
||||
session, current_user.username, password_form.current_password
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid current password",
|
||||
)
|
||||
new_password_hash = hash_password(password_form.new_password)
|
||||
user.hashed_password = new_password_hash
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
origin = "UNKNOWN"
|
||||
if request.client:
|
||||
origin = request.client.host
|
||||
|
||||
await admin.write_audit_message(
|
||||
Operation.UPDATE,
|
||||
message="User changed their password",
|
||||
origin=origin,
|
||||
username=user.username,
|
||||
)
|
||||
|
||||
@app.get("/oidc/login")
|
||||
async def start_oidc_login(
|
||||
request: Request, oidc: Annotated[AdminOidc, Depends(get_oidc_client)]
|
||||
) -> RedirectResponse:
|
||||
"""Redirect for OIDC login."""
|
||||
redirect_url = request.url_for("oidc_callback")
|
||||
return await oidc.start_auth(request, redirect_url)
|
||||
|
||||
@app.get("/oidc/callback")
|
||||
async def oidc_callback(
|
||||
request: Request,
|
||||
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
oidc: Annotated[AdminOidc, Depends(get_oidc_client)],
|
||||
):
|
||||
"""Callback for OIDC auth."""
|
||||
try:
|
||||
claims = await oidc.handle_auth_callback(request)
|
||||
except AuthenticationFailedError as error:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=error)
|
||||
except ValidationError as error:
|
||||
LOG.error("Validation error: %s", error, exc_info=True)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=error)
|
||||
|
||||
# We now have a IdentityClaims object.
|
||||
# We need to check if this matches an existing user, or we need to create a new one.
|
||||
|
||||
user = await handle_oidc_claim(session, claims)
|
||||
user.last_login = datetime.now()
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
# Set cookies
|
||||
token_data: dict[str, str] = {"sub": claims.sub}
|
||||
access_token = create_access_token(
|
||||
dependencies.settings, data=token_data, provider=claims.provider
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
dependencies.settings, data=token_data, provider=claims.provider
|
||||
)
|
||||
|
||||
path = f"/auth_cb#access_token={access_token}&refresh_token={refresh_token}"
|
||||
callback_url = os.path.join("admin", path)
|
||||
if dependencies.settings.frontend_test_url:
|
||||
callback_url = os.path.join(dependencies.settings.frontend_test_url, path)
|
||||
origin = "UNKNOWN"
|
||||
if request.client:
|
||||
origin = request.client.host
|
||||
|
||||
await admin.write_audit_message(
|
||||
operation=Operation.LOGIN,
|
||||
message="Logged in to admin frontend",
|
||||
origin=origin,
|
||||
username=user.username,
|
||||
oidc=claims.provider,
|
||||
)
|
||||
|
||||
return RedirectResponse(callback_url)
|
||||
|
||||
@app.get("/users/me")
|
||||
async def get_current_user(
|
||||
current_user: Annotated[User, Security(dependencies.get_current_active_user)],
|
||||
) -> LocalUserInfo:
|
||||
"""Get information about the user currently logged in."""
|
||||
is_local = current_user.provider is AuthProvider.LOCAL
|
||||
return LocalUserInfo(
|
||||
id=current_user.id, display_name=current_user.username, local=is_local
|
||||
)
|
||||
|
||||
@app.get("/oidc/status")
|
||||
async def get_auth_info() -> LoginInfo:
|
||||
"""Check if OIDC login is available."""
|
||||
if dependencies.settings.oidc:
|
||||
return LoginInfo(
|
||||
enabled=True, oidc_provider=dependencies.settings.oidc.name
|
||||
)
|
||||
return LoginInfo(enabled=False)
|
||||
|
||||
return app
|
||||
|
||||
@ -4,20 +4,50 @@
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from sshecret.backend import Client
|
||||
from sshecret.backend import Client, ClientFilter
|
||||
from sshecret_admin.core.dependencies import AdminDependencies
|
||||
from sshecret_admin.services import AdminBackend
|
||||
from sshecret_admin.services.models import (
|
||||
ClientCreate,
|
||||
ClientListParams,
|
||||
UpdateKeyModel,
|
||||
UpdateKeyResponse,
|
||||
UpdatePoliciesRequest,
|
||||
)
|
||||
|
||||
from sshecret.backend.identifiers import ClientIdParam, FlexID, KeySpec
|
||||
from sshecret.backend.models import ClientQueryResult, ClientReference, FilterType, SystemStats
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
def _id(identifier: str) -> KeySpec:
|
||||
"""Parse ID."""
|
||||
parsed = FlexID.from_string(identifier)
|
||||
return parsed.keyspec
|
||||
|
||||
def query_filter_to_client_filter(query_filter: ClientListParams) -> ClientFilter:
|
||||
"""Convert query filter to client filter."""
|
||||
client_filter = ClientFilter(
|
||||
limit=query_filter.limit,
|
||||
offset=query_filter.offset,
|
||||
order_by=query_filter.order_by,
|
||||
order_reverse=query_filter.order_reverse,
|
||||
)
|
||||
if client_id := query_filter.id:
|
||||
client_filter.id = str(client_id)
|
||||
|
||||
if match_name := query_filter.name:
|
||||
client_filter.name = match_name
|
||||
elif match_name_like := query_filter.name__like:
|
||||
client_filter.name = match_name_like
|
||||
client_filter.filter_name = FilterType.LIKE
|
||||
elif match_name_contains := query_filter.name__contains:
|
||||
client_filter.name = match_name_contains
|
||||
client_filter.filter_name = FilterType.CONTAINS
|
||||
return client_filter
|
||||
|
||||
|
||||
def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
"""Create clients router."""
|
||||
@ -31,6 +61,23 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
clients = await admin.get_clients()
|
||||
return clients
|
||||
|
||||
@app.get("/clients/terse/")
|
||||
async def get_clients_terse(
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> list[ClientReference]:
|
||||
"""Get a list of client ids and names."""
|
||||
return await admin.get_clients_terse()
|
||||
|
||||
@app.get("/query/clients/")
|
||||
async def query_clients(
|
||||
filter_query: Annotated[ClientListParams, Query()],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> ClientQueryResult:
|
||||
"""Query clients."""
|
||||
client_filter = query_filter_to_client_filter(filter_query)
|
||||
clients = await admin.query_clients(client_filter)
|
||||
return clients
|
||||
|
||||
@app.post("/clients/")
|
||||
async def create_client(
|
||||
new_client: ClientCreate,
|
||||
@ -41,26 +88,66 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
if new_client.sources:
|
||||
sources = [str(source) for source in new_client.sources]
|
||||
client = await admin.create_client(
|
||||
new_client.name, new_client.public_key, sources=sources
|
||||
name=new_client.name,
|
||||
public_key=new_client.public_key,
|
||||
description=new_client.description,
|
||||
sources=sources,
|
||||
)
|
||||
return client
|
||||
|
||||
@app.delete("/clients/{name}")
|
||||
@app.get("/clients/{id}")
|
||||
async def get_client(
|
||||
id: ClientIdParam,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> Client:
|
||||
"""Get a client."""
|
||||
client = await admin.get_client(_id(id))
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||
)
|
||||
|
||||
return client
|
||||
|
||||
@app.put("/clients/{id}")
|
||||
async def update_client(
|
||||
id: ClientIdParam,
|
||||
updated: ClientCreate,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> Client:
|
||||
"""Update a client."""
|
||||
client = await admin.get_client(_id(id))
|
||||
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||
)
|
||||
update_fields = {
|
||||
"description": updated.description,
|
||||
"public_key": updated.public_key,
|
||||
"policies": updated.sources,
|
||||
}
|
||||
new_client = client.model_copy(update=update_fields)
|
||||
|
||||
result = await admin.update_client(new_client)
|
||||
return result
|
||||
|
||||
@app.delete("/clients/{id}")
|
||||
async def delete_client(
|
||||
name: str,
|
||||
id: ClientIdParam,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> None:
|
||||
"""Delete a client."""
|
||||
await admin.delete_client(name)
|
||||
await admin.delete_client(_id(id))
|
||||
|
||||
@app.delete("/clients/{name}/secrets/{secret_name}")
|
||||
@app.delete("/clients/{id}/secrets/{secret_name}")
|
||||
async def delete_secret_from_client(
|
||||
name: str,
|
||||
id: ClientIdParam,
|
||||
secret_name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> None:
|
||||
"""Delete a secret from a client."""
|
||||
client = await admin.get_client(name)
|
||||
client = await admin.get_client(_id(id))
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||
@ -70,16 +157,16 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
LOG.debug("Client does not have requested secret. No action to perform.")
|
||||
return None
|
||||
|
||||
await admin.delete_client_secret(name, secret_name)
|
||||
await admin.delete_client_secret(("id", id), secret_name)
|
||||
|
||||
@app.put("/clients/{name}/policies")
|
||||
@app.put("/clients/{id}/policies")
|
||||
async def update_client_policies(
|
||||
name: str,
|
||||
id: str,
|
||||
updated: UpdatePoliciesRequest,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> Client:
|
||||
"""Update the client access policies."""
|
||||
client = await admin.get_client(name)
|
||||
client = await admin.get_client(_id(id))
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||
@ -88,16 +175,16 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
LOG.debug("Old policies: %r. New: %r", client.policies, updated.sources)
|
||||
|
||||
addresses: list[str] = [str(source) for source in updated.sources]
|
||||
await admin.update_client_sources(name, addresses)
|
||||
client = await admin.get_client(name)
|
||||
await admin.update_client_sources(("id", id), addresses)
|
||||
client = await admin.get_client(("id", id))
|
||||
|
||||
assert client is not None, "Critical: The client disappeared after update!"
|
||||
|
||||
return client
|
||||
|
||||
@app.put("/clients/{name}/public-key")
|
||||
@app.put("/clients/{id}/public-key")
|
||||
async def update_client_public_key(
|
||||
name: str,
|
||||
id: ClientIdParam,
|
||||
updated: UpdateKeyModel,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> UpdateKeyResponse:
|
||||
@ -107,18 +194,27 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
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)
|
||||
updated_secrets = await admin.update_client_public_key(
|
||||
_id(id), updated.public_key
|
||||
)
|
||||
return UpdateKeyResponse(
|
||||
public_key=updated.public_key, updated_secrets=updated_secrets
|
||||
)
|
||||
|
||||
@app.put("/clients/{name}/secrets/{secret_name}")
|
||||
@app.put("/clients/{id}/secrets/{secret_name}")
|
||||
async def add_secret_to_client(
|
||||
name: str,
|
||||
id: ClientIdParam,
|
||||
secret_name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> None:
|
||||
"""Add secret to a client."""
|
||||
await admin.create_client_secret(name, secret_name)
|
||||
await admin.create_client_secret(_id(id), secret_name)
|
||||
|
||||
@app.get("/stats")
|
||||
async def get_system_stats(
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> SystemStats:
|
||||
"""Get system stats."""
|
||||
return await admin.get_system_stats()
|
||||
|
||||
return app
|
||||
|
||||
@ -10,12 +10,19 @@ from sshecret_admin.services import AdminBackend
|
||||
from sshecret_admin.services.models import (
|
||||
ClientSecretGroup,
|
||||
ClientSecretGroupList,
|
||||
GroupPath,
|
||||
SecretCreate,
|
||||
SecretGroupAssign,
|
||||
SecretGroupCreate,
|
||||
SecretGroupUdate,
|
||||
SecretListView,
|
||||
SecretUpdate,
|
||||
SecretView,
|
||||
)
|
||||
from sshecret_admin.services.secret_manager import (
|
||||
InvalidGroupNameError,
|
||||
InvalidSecretNameError,
|
||||
)
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
@ -42,6 +49,7 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
value=secret.get_secret(),
|
||||
clients=secret.clients,
|
||||
group=secret.group,
|
||||
distinguisher=secret.client_distinguisher,
|
||||
)
|
||||
|
||||
@app.get("/secrets/{name}")
|
||||
@ -79,22 +87,53 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
async def get_secret_groups(
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
filter_regex: Annotated[str | None, Query()] = None,
|
||||
flat: bool = False,
|
||||
) -> ClientSecretGroupList:
|
||||
"""Get secret groups."""
|
||||
return await admin.get_secret_groups(filter_regex)
|
||||
result = await admin.get_secret_groups(filter_regex, flat=flat)
|
||||
return result
|
||||
|
||||
@app.get("/secrets/groups/{group_name}/")
|
||||
@app.get("/secrets/groups/{group_path:path}/")
|
||||
async def get_secret_group(
|
||||
group_name: str,
|
||||
group_path: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> ClientSecretGroup:
|
||||
"""Get a specific secret group."""
|
||||
results = await admin.get_secret_groups(group_name, False)
|
||||
results = await admin.get_secret_group_by_path(group_path)
|
||||
if not results:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="No such group."
|
||||
)
|
||||
return results.groups[0]
|
||||
return results
|
||||
|
||||
@app.put("/secrets/groups/{group_path:path}/")
|
||||
async def update_secret_group(
|
||||
group_path: str,
|
||||
group: SecretGroupUdate,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> ClientSecretGroup:
|
||||
"""Update a secret group."""
|
||||
existing_group = await admin.lookup_secret_group(group_path)
|
||||
if not existing_group:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="No such group."
|
||||
)
|
||||
|
||||
params: dict[str, str] = {}
|
||||
if name := group.name:
|
||||
params["name"] = name
|
||||
|
||||
if description := group.description:
|
||||
params["description"] = description
|
||||
|
||||
if parent := group.parent_group:
|
||||
params["parent"] = parent
|
||||
|
||||
new_group = await admin.update_secret_group(
|
||||
group_path,
|
||||
**params,
|
||||
)
|
||||
return new_group
|
||||
|
||||
@app.post("/secrets/groups/")
|
||||
async def add_secret_group(
|
||||
@ -108,16 +147,29 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
parent_group=group.parent_group,
|
||||
)
|
||||
|
||||
result = await admin.get_secret_group(group.name)
|
||||
result = await admin.lookup_secret_group(group.name)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Group creation failed"
|
||||
)
|
||||
return result
|
||||
|
||||
@app.delete("/secrets/groups/{group_name}/")
|
||||
@app.delete("/secrets/group/{id}")
|
||||
async def delete_group_id(
|
||||
id: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> None:
|
||||
"""Remove a group by ID."""
|
||||
try:
|
||||
await admin.delete_secret_group_by_id(id)
|
||||
except InvalidGroupNameError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Group ID not found"
|
||||
)
|
||||
|
||||
@app.delete("/secrets/groups/{group_path:path}/")
|
||||
async def delete_secret_group(
|
||||
group_name: str,
|
||||
group_path: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> None:
|
||||
"""Remove a group.
|
||||
@ -125,83 +177,55 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
|
||||
Entries within the group will be moved to the root.
|
||||
This also includes nested entries further down from the group.
|
||||
"""
|
||||
group = await admin.get_secret_group(group_name)
|
||||
group = await admin.get_secret_group_by_path(group_path)
|
||||
if not group:
|
||||
return
|
||||
await admin.delete_secret_group(group_name, keep_entries=True)
|
||||
await admin.delete_secret_group(group_path)
|
||||
|
||||
@app.post("/secrets/groups/{group_name}/{secret_name}")
|
||||
async def move_secret_to_group(
|
||||
group_name: str,
|
||||
secret_name: str,
|
||||
@app.post("/secrets/set-group")
|
||||
async def assign_secret_group(
|
||||
assignment: SecretGroupAssign,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> None:
|
||||
"""Move a secret to a group."""
|
||||
groups = await admin.get_secret_groups(group_name, False)
|
||||
if not groups:
|
||||
"""Assign a secret to a group or root."""
|
||||
try:
|
||||
await admin.set_secret_group(assignment.secret_name, assignment.group_path)
|
||||
except InvalidSecretNameError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="No such group."
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not fount"
|
||||
)
|
||||
except InvalidGroupNameError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Invalid group name"
|
||||
)
|
||||
|
||||
await admin.set_secret_group(secret_name, group_name)
|
||||
|
||||
@app.post("/secrets/group/{group_name}/parent/{parent_name}")
|
||||
@app.post("/secrets/move-group/{group_name:path}")
|
||||
async def move_group(
|
||||
group_name: str,
|
||||
parent_name: str,
|
||||
destination: GroupPath,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> None:
|
||||
"""Move a group."""
|
||||
group = await admin.get_secret_group(group_name)
|
||||
group = await admin.lookup_secret_group(group_name)
|
||||
if not group:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No such group {group_name}",
|
||||
)
|
||||
parent_group = await admin.get_secret_group(parent_name)
|
||||
if not parent_group:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No such group {parent_name}",
|
||||
)
|
||||
await admin.move_secret_group(group_name, parent_name)
|
||||
parent_path: str | None = destination.path
|
||||
if destination.path == "/" or not destination.path:
|
||||
# / means root
|
||||
parent_path = None
|
||||
|
||||
@app.delete("/secrets/group/{group_name}/parent/")
|
||||
async def move_group_to_root(
|
||||
group_name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> None:
|
||||
"""Move a group to the root."""
|
||||
group = await admin.get_secret_group(group_name)
|
||||
if not group:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No such group {group_name}",
|
||||
)
|
||||
LOG.debug("Moving group %s to %r", group_name, parent_path)
|
||||
|
||||
await admin.move_secret_group(group_name, None)
|
||||
|
||||
@app.delete("/secrets/groups/{group_name}/{secret_name}")
|
||||
async def remove_secret_from_group(
|
||||
group_name: str,
|
||||
secret_name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> None:
|
||||
"""Remove a secret from a group.
|
||||
|
||||
Secret will be moved to the root group.
|
||||
"""
|
||||
groups = await admin.get_secret_groups(group_name, False)
|
||||
if not groups:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="No such group."
|
||||
)
|
||||
group = groups.groups[0]
|
||||
matching_entries = [
|
||||
entry for entry in group.entries if entry.name == secret_name
|
||||
]
|
||||
if not matching_entries:
|
||||
return
|
||||
await admin.set_secret_group(secret_name, None)
|
||||
if parent_path:
|
||||
parent_group = await admin.get_secret_group_by_path(destination.path)
|
||||
if not parent_group:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"No such group {parent_path}",
|
||||
)
|
||||
await admin.move_secret_group(group_name, parent_path)
|
||||
|
||||
return app
|
||||
|
||||
@ -5,18 +5,19 @@
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from fastapi.security.utils import get_authorization_scheme_param
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from sshecret_admin.services.admin_backend import AdminBackend
|
||||
from sshecret_admin.core.dependencies import BaseDependencies, AdminDependencies
|
||||
from sshecret_admin.auth import PasswordDB, User, decode_token
|
||||
from sshecret_admin.auth import User, decode_token
|
||||
from sshecret_admin.auth.constants import LOCAL_ISSUER
|
||||
|
||||
from .endpoints import auth, clients, secrets
|
||||
from .endpoints import audit, auth, clients, secrets
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
@ -26,7 +27,7 @@ API_VERSION = "v1"
|
||||
def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||
"""Create clients router."""
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token", refreshUrl="/api/v1/refresh")
|
||||
|
||||
async def get_current_user(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
@ -57,6 +58,31 @@ def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||
raise credentials_exception
|
||||
return user
|
||||
|
||||
def get_client_origin(request: Request) -> str:
|
||||
"""Get client origin."""
|
||||
fallback_origin = "UNKNOWN"
|
||||
if request.client:
|
||||
return request.client.host
|
||||
return fallback_origin
|
||||
|
||||
def get_optional_username(request: Request) -> str | None:
|
||||
"""Get username, if available.
|
||||
|
||||
This is purely used for auditing purposes.
|
||||
"""
|
||||
authorization = request.headers.get("Authorization")
|
||||
scheme, param = get_authorization_scheme_param(authorization)
|
||||
if not authorization or scheme.lower() != "bearer":
|
||||
return None
|
||||
claims = decode_token(dependencies.settings, param)
|
||||
if not claims:
|
||||
return None
|
||||
|
||||
if claims.provider == LOCAL_ISSUER:
|
||||
return claims.sub
|
||||
|
||||
return f"oidc:{claims.email}"
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
@ -66,17 +92,16 @@ def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||
return current_user
|
||||
|
||||
async def get_admin_backend(
|
||||
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||
request: Request,
|
||||
):
|
||||
"""Get admin backend API."""
|
||||
password_db = session.scalars(
|
||||
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(dependencies.settings, password_db.encrypted_password)
|
||||
username = get_optional_username(request)
|
||||
origin = get_client_origin(request)
|
||||
admin = AdminBackend(
|
||||
dependencies.settings,
|
||||
username=username,
|
||||
origin=origin,
|
||||
)
|
||||
yield admin
|
||||
|
||||
app = APIRouter(prefix=f"/api/{API_VERSION}")
|
||||
@ -87,6 +112,7 @@ def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||
|
||||
LOG.debug("Registering sub-routers")
|
||||
|
||||
app.include_router(audit.create_router(endpoint_deps))
|
||||
app.include_router(auth.create_router(endpoint_deps))
|
||||
app.include_router(clients.create_router(endpoint_deps))
|
||||
app.include_router(secrets.create_router(endpoint_deps))
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
"""Models for authentication."""
|
||||
"""Models for authentication and secret management."""
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from typing import override
|
||||
import uuid
|
||||
import sqlalchemy as sa
|
||||
from pydantic import BaseModel
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
JWT_ALGORITHM = "HS256"
|
||||
@ -75,12 +76,15 @@ class PasswordDB(Base):
|
||||
__tablename__: str = "password_db"
|
||||
|
||||
id: Mapped[int] = mapped_column(sa.INT, primary_key=True)
|
||||
encrypted_password: Mapped[str] = mapped_column(sa.String)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
)
|
||||
|
||||
client_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
sa.Uuid(as_uuid=True), nullable=True
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
@ -88,6 +92,65 @@ class PasswordDB(Base):
|
||||
)
|
||||
|
||||
|
||||
class Group(Base):
|
||||
"""A secret group."""
|
||||
|
||||
__tablename__: str = "groups"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
sa.Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(sa.String, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(sa.String, nullable=True)
|
||||
|
||||
parent_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
sa.ForeignKey("groups.id"), nullable=True
|
||||
)
|
||||
parent: Mapped["Group | None"] = relationship(
|
||||
"Group", remote_side=[id], back_populates="children"
|
||||
)
|
||||
children: Mapped[list["Group"]] = relationship(
|
||||
"Group", back_populates="parent", cascade="all, delete"
|
||||
)
|
||||
secrets: Mapped[list["ManagedSecret"]] = relationship(back_populates="group")
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
return f"<Group id={self.id} name={self.name} parent_id={self.parent_id}>"
|
||||
|
||||
|
||||
class ManagedSecret(Base):
|
||||
"""Managed Secret."""
|
||||
|
||||
__tablename__: str = "managed_secrets"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
sa.Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(sa.String, nullable=False)
|
||||
|
||||
is_deleted: Mapped[bool] = mapped_column(sa.Boolean, default=False)
|
||||
|
||||
group_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
sa.ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
group: Mapped["Group | None"] = relationship(
|
||||
Group, foreign_keys=[group_id], back_populates="secrets"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
)
|
||||
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
sa.DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class IdentityClaims(BaseModel):
|
||||
"""Normalized identity claim model."""
|
||||
|
||||
@ -105,6 +168,7 @@ class TokenData(BaseModel):
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
@ -125,6 +189,8 @@ class LocalUserInfo(BaseModel):
|
||||
local: bool
|
||||
|
||||
|
||||
def init_db(engine: sa.Engine) -> None:
|
||||
"""Create database."""
|
||||
Base.metadata.create_all(engine)
|
||||
class LoginInfo(BaseModel):
|
||||
"""Model containing information about login providers."""
|
||||
|
||||
enabled: bool
|
||||
oidc_provider: str | None = None
|
||||
|
||||
@ -2,87 +2,82 @@
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
#
|
||||
from collections.abc import AsyncGenerator
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request, Response, status
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sshecret_backend.db import DatabaseSessionManager
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from sshecret_admin import api, frontend
|
||||
from sshecret_admin.auth.models import PasswordDB, init_db
|
||||
from sshecret_admin import api
|
||||
from sshecret_admin.auth.models import Base
|
||||
from sshecret_admin.core.db import setup_database
|
||||
from sshecret_admin.frontend.exceptions import RedirectException
|
||||
from sshecret_admin.services.master_password import setup_master_password
|
||||
from sshecret_admin.services.secret_manager import setup_private_key
|
||||
|
||||
from sshecret.backend.exceptions import BackendError, BackendValidationError
|
||||
|
||||
from .dependencies import BaseDependencies
|
||||
from .settings import AdminServerSettings
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
# dir_path = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
|
||||
def setup_frontend(app: FastAPI, dependencies: BaseDependencies) -> None:
|
||||
"""Setup frontend."""
|
||||
script_path = Path(os.path.dirname(os.path.realpath(__file__)))
|
||||
static_path = script_path.parent / "static"
|
||||
|
||||
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
||||
app.include_router(frontend.create_frontend_router(dependencies))
|
||||
def valid_frontend_directory(frontend_dir: pathlib.Path) -> bool:
|
||||
"""Validate frontend dir."""
|
||||
if not frontend_dir.exists():
|
||||
return False
|
||||
if not frontend_dir.is_dir():
|
||||
return False
|
||||
if (frontend_dir / "index.html").exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def create_admin_app(
|
||||
settings: AdminServerSettings, with_frontend: bool = True
|
||||
settings: AdminServerSettings,
|
||||
create_db: bool = False,
|
||||
) -> FastAPI:
|
||||
"""Create admin app."""
|
||||
engine, get_db_session = setup_database(settings.admin_db)
|
||||
|
||||
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Get async session."""
|
||||
session_manager = DatabaseSessionManager(settings.async_db_url)
|
||||
async with session_manager.session() as session:
|
||||
yield session
|
||||
|
||||
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.scalars(
|
||||
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()
|
||||
LOG.info("Setting up password manager")
|
||||
setup_private_key(settings, regenerate=False)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
"""Create database before starting the server."""
|
||||
init_db(engine)
|
||||
if create_db:
|
||||
LOG.info("Setting up database")
|
||||
Base.metadata.create_all(engine)
|
||||
setup_password_manager()
|
||||
yield
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.secret_key)
|
||||
origins = [settings.frontend_origin]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
@ -93,14 +88,23 @@ def create_admin_app(
|
||||
content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
|
||||
)
|
||||
|
||||
@app.exception_handler(RedirectException)
|
||||
async def redirect_handler(request: Request, exc: RedirectException) -> Response:
|
||||
"""Handle redirect exceptions."""
|
||||
if "hx-request" in request.headers:
|
||||
response = Response()
|
||||
response.headers["HX-Redirect"] = str(exc.to)
|
||||
return response
|
||||
return RedirectResponse(url=str(exc.to))
|
||||
@app.exception_handler(BackendValidationError)
|
||||
async def validation_backend_validation_exception_handler(
|
||||
request: Request, exc: BackendValidationError
|
||||
):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content=jsonable_encoder({"detail": exc.errors()}),
|
||||
)
|
||||
|
||||
@app.exception_handler(BackendError)
|
||||
async def validation_backend_exception_handler(
|
||||
request: Request, exc: BackendValidationError
|
||||
):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content=jsonable_encoder({"detail": [str(exc)]}),
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
async def get_health() -> JSONResponse:
|
||||
@ -109,10 +113,28 @@ def create_admin_app(
|
||||
status_code=status.HTTP_200_OK, content=jsonable_encoder({"status": "LIVE"})
|
||||
)
|
||||
|
||||
dependencies = BaseDependencies(settings, get_db_session)
|
||||
dependencies = BaseDependencies(settings, get_db_session, get_async_session)
|
||||
|
||||
app.include_router(api.create_api_router(dependencies))
|
||||
if with_frontend:
|
||||
setup_frontend(app, dependencies)
|
||||
|
||||
@app.get("/")
|
||||
def serve_frontend(request: Request) -> FileResponse:
|
||||
"""Serve the frontend SPA index."""
|
||||
LOG.info("Got this request: %r", request.url)
|
||||
if not settings.frontend_dir:
|
||||
raise HTTPException(status_code=404, detail="Not found.")
|
||||
return FileResponse(settings.frontend_dir / "index.html")
|
||||
|
||||
@app.get("/{frontend_path:path}")
|
||||
def serve_frontend_path(frontend_path: str) -> FileResponse:
|
||||
"""Serve the frontend SPA.."""
|
||||
LOG.info("Got request for %s", frontend_path)
|
||||
if not settings.frontend_dir:
|
||||
raise HTTPException(status_code=404, detail="Not found.")
|
||||
static_file = settings.frontend_dir / frontend_path
|
||||
if static_file.exists() and static_file.is_file():
|
||||
return FileResponse(static_file)
|
||||
return FileResponse(settings.frontend_dir / "index.html")
|
||||
|
||||
|
||||
return app
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
"""Sshecret admin CLI helper."""
|
||||
|
||||
import asyncio
|
||||
import code
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Awaitable
|
||||
from typing import Any, cast
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import click
|
||||
import uvicorn
|
||||
@ -12,9 +11,9 @@ from pydantic import ValidationError
|
||||
from sqlalchemy import select, create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sshecret_admin.auth.authentication import hash_password
|
||||
from sshecret_admin.auth.models import AuthProvider, PasswordDB, User, init_db
|
||||
from sshecret_admin.auth.models import AuthProvider, User
|
||||
from sshecret_admin.core.app import create_admin_app
|
||||
from sshecret_admin.core.settings import AdminServerSettings
|
||||
from sshecret_admin.services.admin_backend import AdminBackend
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter(
|
||||
@ -72,7 +71,6 @@ def cli_create_user(
|
||||
"""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, email, password)
|
||||
|
||||
@ -87,7 +85,6 @@ def cli_change_user_passwd(ctx: click.Context, username: str, password: str) ->
|
||||
"""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.scalars(select(User).where(User.username == username)).first()
|
||||
if not user:
|
||||
@ -107,7 +104,6 @@ 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.scalars(select(User).where(User.username == username)).first()
|
||||
if not user:
|
||||
@ -143,33 +139,19 @@ def cli_run(
|
||||
)
|
||||
|
||||
|
||||
@cli.command("repl")
|
||||
@cli.command("openapi")
|
||||
@click.argument("destination", type=click.Path(file_okay=False, dir_okay=True, path_type=Path))
|
||||
@click.pass_context
|
||||
def cli_repl(ctx: click.Context) -> None:
|
||||
"""Run an interactive console."""
|
||||
def cli_generate_openapi(ctx: click.Context, destination: Path) -> None:
|
||||
"""Generate openapi schema.
|
||||
|
||||
A openapi.json file will be written to the destination directory.
|
||||
"""
|
||||
settings = cast(AdminServerSettings, ctx.obj)
|
||||
engine = create_engine(settings.admin_db)
|
||||
init_db(engine)
|
||||
with Session(engine) as session:
|
||||
password_db = session.scalars(
|
||||
select(PasswordDB).where(PasswordDB.id == 1)
|
||||
).first()
|
||||
app = create_admin_app(settings, with_frontend=False)
|
||||
schema = app.openapi()
|
||||
output_file = destination / "openapi.json"
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(schema, f)
|
||||
|
||||
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!")
|
||||
click.echo(f"Wrote schema to {output_file.absolute()}")
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
"""Database setup."""
|
||||
|
||||
import sqlite3
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from collections.abc import AsyncIterator, Generator, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.engine import URL
|
||||
from sqlalchemy import create_engine, Engine
|
||||
from sqlalchemy import create_engine, Engine, event
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncConnection,
|
||||
@ -18,11 +19,20 @@ from sqlalchemy.ext.asyncio import (
|
||||
|
||||
|
||||
def setup_database(
|
||||
db_url: URL | str,
|
||||
db_url: URL,
|
||||
) -> tuple[Engine, Callable[[], Generator[Session, None, None]]]:
|
||||
"""Setup database."""
|
||||
|
||||
engine = create_engine(db_url, echo=True, future=True)
|
||||
engine = create_engine(db_url, echo=False, future=True)
|
||||
if db_url.drivername.startswith("sqlite"):
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def set_sqlite_pragma(
|
||||
dbapi_connection: sqlite3.Connection, _connection_record: object
|
||||
) -> None:
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
def get_db_session() -> Generator[Session, None, None]:
|
||||
"""Get DB Session."""
|
||||
@ -33,8 +43,18 @@ def setup_database(
|
||||
|
||||
|
||||
class DatabaseSessionManager:
|
||||
def __init__(self, host: URL | str, **engine_kwargs: str):
|
||||
def __init__(self, host: URL, **engine_kwargs: str):
|
||||
self._engine: AsyncEngine | None = create_async_engine(host, **engine_kwargs)
|
||||
if host.drivername.startswith("sqlite+"):
|
||||
|
||||
@event.listens_for(self._engine.sync_engine, "connect")
|
||||
def set_sqlite_pragma(
|
||||
dbapi_connection: sqlite3.Connection, _connection_record: object
|
||||
) -> None:
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
self._sessionmaker: async_sessionmaker[AsyncSession] | None = (
|
||||
async_sessionmaker(
|
||||
autocommit=False, bind=self._engine, expire_on_commit=False
|
||||
|
||||
@ -4,6 +4,8 @@ from collections.abc import AsyncGenerator, Awaitable, Callable, Generator
|
||||
from dataclasses import dataclass
|
||||
from typing import Self
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
from sshecret_admin.auth import User
|
||||
from sshecret_admin.services import AdminBackend
|
||||
@ -11,8 +13,9 @@ from sshecret_admin.core.settings import AdminServerSettings
|
||||
|
||||
|
||||
DBSessionDep = Callable[[], Generator[Session, None, None]]
|
||||
AsyncSessionDep = Callable[[], AsyncGenerator[AsyncSession, None]]
|
||||
|
||||
AdminDep = Callable[[Session], AsyncGenerator[AdminBackend, None]]
|
||||
AdminDep = Callable[[Request], AsyncGenerator[AdminBackend, None]]
|
||||
|
||||
GetUserDep = Callable[[User], Awaitable[User]]
|
||||
|
||||
@ -23,6 +26,8 @@ class BaseDependencies:
|
||||
|
||||
settings: AdminServerSettings
|
||||
get_db_session: DBSessionDep
|
||||
get_async_session: AsyncSessionDep
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -43,6 +48,7 @@ class AdminDependencies(BaseDependencies):
|
||||
return cls(
|
||||
settings=deps.settings,
|
||||
get_db_session=deps.get_db_session,
|
||||
get_async_session=deps.get_async_session,
|
||||
get_admin_backend=get_admin_backend,
|
||||
get_current_active_user=get_current_active_user,
|
||||
)
|
||||
|
||||
@ -39,6 +39,9 @@ class AdminServerSettings(BaseSettings):
|
||||
debug: bool = False
|
||||
password_manager_directory: Path | None = None
|
||||
oidc: OidcSettings | None = None
|
||||
frontend_origin: str = Field(default="*")
|
||||
frontend_test_url: str | None = Field(default=None)
|
||||
frontend_dir: Path | None = None
|
||||
|
||||
@property
|
||||
def admin_db(self) -> URL:
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
"""Frontend app."""
|
||||
|
||||
from .router import create_router as create_frontend_router
|
||||
|
||||
__all__ = ["create_frontend_router"]
|
||||
@ -1,7 +0,0 @@
|
||||
"""Custom oauth2 class."""
|
||||
|
||||
from fastapi.security import OAuth2
|
||||
|
||||
|
||||
class Oauth2TokenInCookies(OAuth2):
|
||||
"""TODO: Create this."""
|
||||
@ -1,59 +0,0 @@
|
||||
"""Frontend dependencies."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import AsyncGenerator, Callable, Awaitable
|
||||
from typing import Self
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
||||
from fastapi import Request
|
||||
|
||||
from sshecret_admin.core.dependencies import AdminDep, BaseDependencies
|
||||
|
||||
from sshecret_admin.auth.models import IdentityClaims, LocalUserInfo, User
|
||||
|
||||
UserTokenDep = Callable[[Request, Session], Awaitable[User]]
|
||||
LoginStatusDep = Callable[[Request], Awaitable[bool]]
|
||||
AsyncSessionDep = Callable[[], AsyncGenerator[AsyncSession, None]]
|
||||
UserInfoDep = Callable[[Request, AsyncSession], Awaitable[LocalUserInfo]]
|
||||
RefreshTokenDep = Callable[[Request], IdentityClaims]
|
||||
LoginGuardDep = Callable[[Request], Awaitable[None]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrontendDependencies(BaseDependencies):
|
||||
"""Frontend dependencies."""
|
||||
|
||||
get_admin_backend: AdminDep
|
||||
templates: Jinja2Blocks
|
||||
get_refresh_claims: RefreshTokenDep
|
||||
get_login_status: LoginStatusDep
|
||||
get_user_info: UserInfoDep
|
||||
get_async_session: AsyncSessionDep
|
||||
require_login: LoginGuardDep
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
deps: BaseDependencies,
|
||||
get_admin_backend: AdminDep,
|
||||
templates: Jinja2Blocks,
|
||||
get_refresh_claims: RefreshTokenDep,
|
||||
get_login_status: LoginStatusDep,
|
||||
get_user_info: UserInfoDep,
|
||||
get_async_session: AsyncSessionDep,
|
||||
require_login: LoginGuardDep,
|
||||
) -> Self:
|
||||
"""Create from base dependencies."""
|
||||
return cls(
|
||||
settings=deps.settings,
|
||||
get_db_session=deps.get_db_session,
|
||||
get_admin_backend=get_admin_backend,
|
||||
templates=templates,
|
||||
get_refresh_claims=get_refresh_claims,
|
||||
get_login_status=get_login_status,
|
||||
get_user_info=get_user_info,
|
||||
get_async_session=get_async_session,
|
||||
require_login=require_login,
|
||||
)
|
||||
@ -1,14 +0,0 @@
|
||||
"""Frontend exceptions."""
|
||||
|
||||
from starlette.datastructures import URL
|
||||
|
||||
|
||||
class RedirectException(Exception):
|
||||
"""Exception that initiates a redirect flow."""
|
||||
|
||||
def __init__(self, to: str | URL) -> None: # pyright: ignore[reportMissingSuperCall]
|
||||
"""Raise exception that redirects."""
|
||||
if isinstance(to, str):
|
||||
to = URL(to)
|
||||
|
||||
self.to: URL = to
|
||||
@ -1,164 +0,0 @@
|
||||
"""Frontend router."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
from sshecret_admin.auth.authentication import generate_user_info
|
||||
from sshecret_admin.auth.models import AuthProvider, IdentityClaims, LocalUserInfo
|
||||
from starlette.datastructures import URL
|
||||
|
||||
|
||||
from sshecret_admin.auth import PasswordDB, User, decode_token
|
||||
from sshecret_admin.auth.constants import LOCAL_ISSUER
|
||||
|
||||
from sshecret_admin.core.dependencies import BaseDependencies
|
||||
from sshecret_admin.services.admin_backend import AdminBackend
|
||||
from sshecret_admin.core.db import DatabaseSessionManager
|
||||
|
||||
from .dependencies import FrontendDependencies
|
||||
from .exceptions import RedirectException
|
||||
from .views import audit, auth, clients, index, secrets, oidc_auth
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
access_token = "access_token"
|
||||
refresh_token = "refresh_token"
|
||||
|
||||
|
||||
def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||
"""Create frontend router."""
|
||||
|
||||
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)
|
||||
|
||||
async def get_admin_backend(
|
||||
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||
):
|
||||
"""Get admin backend API."""
|
||||
password_db = session.scalars(
|
||||
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(dependencies.settings, password_db.encrypted_password)
|
||||
yield admin
|
||||
|
||||
def get_identity_claims(request: Request) -> IdentityClaims:
|
||||
"""Get identity claim from session."""
|
||||
token = request.cookies.get("access_token")
|
||||
next = URL("/refresh").include_query_params(next=request.url.path)
|
||||
credentials_error = RedirectException(to=next)
|
||||
if not token:
|
||||
raise credentials_error
|
||||
claims = decode_token(dependencies.settings, token)
|
||||
if not claims:
|
||||
raise credentials_error
|
||||
return claims
|
||||
|
||||
def refresh_identity_claims(request: Request) -> IdentityClaims:
|
||||
"""Get identity claim from session for refreshing the token."""
|
||||
token = request.cookies.get("refresh_token")
|
||||
next = URL("/login").include_query_params(next=request.url.path)
|
||||
credentials_error = RedirectException(to=next)
|
||||
if not token:
|
||||
raise credentials_error
|
||||
claims = decode_token(dependencies.settings, token)
|
||||
if not claims:
|
||||
raise credentials_error
|
||||
return claims
|
||||
|
||||
async def get_login_status(request: Request) -> bool:
|
||||
"""Get login status."""
|
||||
token = request.cookies.get("access_token")
|
||||
if not token:
|
||||
return False
|
||||
|
||||
claims = decode_token(dependencies.settings, token)
|
||||
return claims is not None
|
||||
|
||||
async def require_login(request: Request) -> None:
|
||||
"""Enforce login requirement."""
|
||||
token = request.cookies.get("access_token")
|
||||
LOG.info("User has no cookie")
|
||||
if not token:
|
||||
url = URL("/login").include_query_params(next=request.url.path)
|
||||
raise RedirectException(to=url)
|
||||
is_logged_in = await get_login_status(request)
|
||||
if not is_logged_in:
|
||||
next = URL("/refresh").include_query_params(next=request.url.path)
|
||||
raise RedirectException(to=next)
|
||||
|
||||
async def get_async_session():
|
||||
"""Get async session."""
|
||||
sessionmanager = DatabaseSessionManager(dependencies.settings.async_db_url)
|
||||
async with sessionmanager.session() as session:
|
||||
yield session
|
||||
|
||||
async def get_user_info(
|
||||
request: Request, session: Annotated[AsyncSession, Depends(get_async_session)]
|
||||
) -> LocalUserInfo:
|
||||
"""Get User information."""
|
||||
claims = get_identity_claims(request)
|
||||
if claims.provider == LOCAL_ISSUER:
|
||||
LOG.info("Local user, finding username %s", claims.sub)
|
||||
query = (
|
||||
select(User)
|
||||
.where(User.username == claims.sub)
|
||||
.where(User.provider == AuthProvider.LOCAL)
|
||||
)
|
||||
else:
|
||||
query = (
|
||||
select(User)
|
||||
.where(User.oidc_issuer == claims.provider)
|
||||
.where(User.oidc_sub == claims.sub)
|
||||
)
|
||||
|
||||
result = await session.scalars(query)
|
||||
if user := result.first():
|
||||
if user.disabled:
|
||||
raise RedirectException(to=URL("/logout"))
|
||||
return generate_user_info(user)
|
||||
|
||||
next = URL("/refresh").include_query_params(next=request.url.path)
|
||||
raise RedirectException(to=next)
|
||||
|
||||
view_dependencies = FrontendDependencies.create(
|
||||
dependencies,
|
||||
get_admin_backend,
|
||||
templates,
|
||||
refresh_identity_claims,
|
||||
get_login_status,
|
||||
get_user_info,
|
||||
get_async_session,
|
||||
require_login,
|
||||
)
|
||||
|
||||
app.include_router(audit.create_router(view_dependencies))
|
||||
app.include_router(auth.create_router(view_dependencies))
|
||||
app.include_router(clients.create_router(view_dependencies))
|
||||
app.include_router(index.create_router(view_dependencies))
|
||||
app.include_router(secrets.create_router(view_dependencies))
|
||||
if dependencies.settings.oidc:
|
||||
app.include_router(oidc_auth.create_router(view_dependencies))
|
||||
|
||||
return app
|
||||
@ -1,48 +0,0 @@
|
||||
<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"
|
||||
>
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">{{ entry.subsystem }}</span>
|
||||
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">{{ entry.operation }}</span>
|
||||
|
||||
{% if entry.client_id %}
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">
|
||||
Client: <abbr title="{{ entry.client_id }}">{{ entry.client_name }}</abbr>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if entry.secret_name %}
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">
|
||||
Secret:<abbr title="{{ entry.secret_id }}">{{ entry.secret_name }}</abbr>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if entry.data %}
|
||||
{% for key, value in entry.data.items() %}
|
||||
|
||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-sm dark:bg-gray-700 dark:text-gray-300">
|
||||
{{ key }}:{{ value }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
</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>
|
||||
|
||||
</tr>
|
||||
@ -1,7 +0,0 @@
|
||||
{% extends "/base/page.html.j2" %}
|
||||
{% block title %}Audit{% endblock %}
|
||||
{% block page_content %}
|
||||
<div id="auditContent">
|
||||
{% include 'audit/inner.html.j2' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,183 +0,0 @@
|
||||
<div class="flowbite-init-target">
|
||||
<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 dark:divide-gray-600">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
Timestamp
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
<a id="filterSubsystem" data-dropdown-toggle="filterSubsystemsDropdown" class="whitespace-nowrap inline-flex items-center font-medium text-gray-500 hover:underline">
|
||||
Subsystem <svg class="w-[12px] h-[12px] 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="M18.425 10.271C19.499 8.967 18.57 7 16.88 7H7.12c-1.69 0-2.618 1.967-1.544 3.271l4.881 5.927a2 2 0 0 0 3.088 0l4.88-5.927Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
||||
</a>
|
||||
<div id="filterSubsystemsDropdown" class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||
<div class="py-2">
|
||||
<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">All</a>
|
||||
</div>
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="filterSubsystem">
|
||||
<li>
|
||||
<a href="?subsystem=admin" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Admin</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="?subsystem=sshd" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Ssh Server</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="?subsystem=backend" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Backend</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
<a id="filterOperation" data-dropdown-toggle="filterOperationsDropdown" class="whitespace-nowrap inline-flex items-center font-medium text-gray-500 hover:underline">
|
||||
Operation <svg class="w-[12px] h-[12px] 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="M18.425 10.271C19.499 8.967 18.57 7 16.88 7H7.12c-1.69 0-2.618 1.967-1.544 3.271l4.881 5.927a2 2 0 0 0 3.088 0l4.88-5.927Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</a>
|
||||
<div id="filterOperationsDropdown" class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||
<div class="py-2">
|
||||
<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">All</a>
|
||||
</div>
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="filterSubsystem">
|
||||
{% for operation in operations %}
|
||||
<li>
|
||||
<a href="?operation={{ operation }}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">{{ operation }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
Client
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
Secret
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
Message
|
||||
</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">
|
||||
Origin
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800">
|
||||
|
||||
{% for entry in entries | list %}
|
||||
<tr
|
||||
class="{{ loop.cycle('', 'bg-gray-50 dark:bg-gray-700 ') }}hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
id="entry-{{ entry.id }}"
|
||||
>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
<p>{{ entry.timestamp }}<button data-popover-target="popover-audit-entry-{{ entry.id }}" data-popover-placement="bottom-end" type="button"><svg class="w-4 h-4 ms-2 text-gray-400 hover:text-gray-500" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"></path></svg><span class="sr-only">Show information</span></button></p>
|
||||
|
||||
<div data-popover id="popover-audit-entry-{{entry.id}}" role="tooltip" class="absolute z-10 invisible inline-block text-sm text-gray-500 transition-opacity duration-300 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 w-80 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
|
||||
<dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700 px-2 py-2">
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">ID</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.id }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Subsystem</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.subsystem }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Timestamp</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.timestamp }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Operation</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.operation }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client ID</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.client_id }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client Name</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.client_name }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret ID</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.secret_id }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret Name</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.secret_name }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Message</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.message }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Origin</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.origin }}</dd>
|
||||
</div>
|
||||
{% if entry.data %}
|
||||
{% for key, value in entry.data.items() %}
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">{{ key | capitalize }}</dt>
|
||||
<dd class="text-xs font-semibold">{{ value }}</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.subsystem }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.operation }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
|
||||
{% if entry.client_name %}
|
||||
<abbr title="{{ entry.client_id }}">{{ entry.client_name }}</abbr>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{% if entry.secret_name %}
|
||||
<abbr title="{{ entry.secret_id }}">{{ entry.secret_name }}</abbr>
|
||||
{% endif %}
|
||||
</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>
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'audit/pagination.html.j2' %}
|
||||
</div>
|
||||
@ -1,55 +0,0 @@
|
||||
<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>
|
||||
@ -1,71 +0,0 @@
|
||||
|
||||
<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
|
||||
{% if page_info.total < page_info.last %}
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{page_info.first }}-{{ page_info.total}}</span> of
|
||||
{% else %}
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{page_info.first }}-{{ page_info.last}}</span> of
|
||||
{% endif %}
|
||||
<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>
|
||||
@ -1,50 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}Sshecret Admin{% endblock %}</title>
|
||||
|
||||
{% block head %}
|
||||
{% include 'base/partials/stylesheets.html.j2' %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body class="bg-gray-50 text-gray-900 dark:bg-gray-900 min-h-screen flex flex-col">
|
||||
|
||||
<!-- Layout Container -->
|
||||
<div class="flex flex-1 h-full overflow-hidden">
|
||||
|
||||
<!-- Sidebar -->
|
||||
|
||||
<aside class="hidden md:flex md:w-64 flex-col h-full min-h-screen bg-white border-r border-gray-300 dark:bg-gray-800 dark:border-gray-700" id="sidebar" aria-label="sidebar">
|
||||
{% include "base/partials/sidebar.html.j2" %}
|
||||
</aside>
|
||||
<!-- Main Panel -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
|
||||
<!-- Topbar -->
|
||||
<header class="bg-white border-b px-4 py-3 border-gray-300 dark:bg-gray-800 dark:border-gray-700">
|
||||
{% include "base/partials/navbar.html.j2" %}
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main id="content" class="flex-1 overflow-y-auto" hx-target="this" hx-swap="innerHTML">
|
||||
{% block breadcrumbs %}
|
||||
{% endblock %}
|
||||
<div class="" id="maincontent">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% block scripts %}
|
||||
{% include 'base/partials/scripts.html.j2' %}
|
||||
{% endblock %}
|
||||
{% block local_scripts %}
|
||||
{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@ -1,26 +0,0 @@
|
||||
{% extends 'base/page.html.j2' %}
|
||||
|
||||
{% block page_content %}
|
||||
|
||||
<!-- Master-Detail Split View -->
|
||||
<div class="flex h-[calc(100vh-8rem)] overflow-hidden">
|
||||
|
||||
<!-- Master Pane -->
|
||||
<aside id="master-pane"
|
||||
class="md:w-80 w-full shrink-0 border-r overflow-y-auto bg-white md:block border-gray-200 p-4 dark:bg-gray-800 dark:border-gray-700">
|
||||
{% block master %}
|
||||
<p class="p-4 text-gray-500">Master view (e.g. list/tree)</p>
|
||||
{% endblock %}
|
||||
</aside>
|
||||
|
||||
<!-- Detail Pane -->
|
||||
<section id="detail-pane"
|
||||
class="flex-1 flex overflow-y-auto bg-white p-4 hidden md:block dark:bg-gray-800">
|
||||
{% block detail %}
|
||||
<p class="p-4 text-gray-500 dark:text-gray-200">Select an item to view details</p>
|
||||
{% endblock %}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@ -1,27 +0,0 @@
|
||||
{% extends 'base/page.html.j2' %}
|
||||
|
||||
|
||||
{% block title %}Clients{% endblock %}
|
||||
|
||||
{% block page_content %}
|
||||
|
||||
<!-- Master-Detail Layout -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-4">
|
||||
|
||||
<!-- Master (e.g., tree or list) -->
|
||||
<div id="master-pane">
|
||||
{% block master %}
|
||||
<p>Master list goes here</p>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Detail (loaded by HTMX or inline) -->
|
||||
<div id="detail-pane" class="bg-white rounded shadow p-4">
|
||||
{% block detail %}
|
||||
<p>Select an item from the list to view details.</p>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@ -1,40 +0,0 @@
|
||||
{% extends "/base/base.html.j2" %}
|
||||
|
||||
|
||||
{% block breadcrumbs %}
|
||||
|
||||
<div class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<nav class="text-sm text-gray-500" aria-label="Breadcrumb">
|
||||
|
||||
<sl-breadcrumb id="breadcrumbs">
|
||||
<sl-breadcrumb-item>
|
||||
<sl-icon slot="prefix" name="house"></sl-icon>
|
||||
<a href="/">Home</a>
|
||||
</sl-breadcrumb-item>
|
||||
{% if breadcrumbs %}
|
||||
{% for label, url in breadcrumbs %}
|
||||
<sl-breadcrumb-item class="page-breadcrumb">
|
||||
{% if url %}
|
||||
<a href="{{url}}">{{label}}</a>
|
||||
{% else %}
|
||||
{{ label }}
|
||||
{% endif %}
|
||||
</sl-breadcrumb-item>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</sl-breadcrumb>
|
||||
</nav>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
|
||||
<!-- Page Content -->
|
||||
<section class="bg-white dark:bg-gray-800">
|
||||
{% block page_content %}
|
||||
<p>This is a generic page.</p>
|
||||
{% endblock %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
@ -1,68 +0,0 @@
|
||||
<header class="flex items-center justify-between">
|
||||
|
||||
<!-- Left: Sidebar toggle (for mobile) + Title -->
|
||||
<div class="flex items-center space-x-4">
|
||||
|
||||
<!-- Mobile sidebar toggle -->
|
||||
<button
|
||||
id="sidebar-toggle"
|
||||
aria-expanded="true"
|
||||
aria-controls="mobile-sidebar"
|
||||
class="md:hidden text-gray-600 hover:text-gray-900 focus:outline-none"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<sl-icon name="list" class="text-xl"></sl-icon>
|
||||
</button>
|
||||
|
||||
<!-- Page title or logo -->
|
||||
{% if page_title %}
|
||||
<h1 class="text-xl flex ml-2 md:mr-24 font-semibold text-gray-900 sm:text-2xl dark:text-white">{{page_title}}</h1>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: User menu -->
|
||||
<div class="relative">
|
||||
<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>
|
||||
<sl-avatar label="User avatar"></sl-avatar>
|
||||
</button>
|
||||
<!-- Dropdown placeholder -->
|
||||
<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.display_name }}
|
||||
</p>
|
||||
</div>
|
||||
<ul class="py-1" role="none">
|
||||
{% if user.local %}
|
||||
<li>
|
||||
<a
|
||||
href="/password"
|
||||
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>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a
|
||||
href="/logout"
|
||||
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>
|
||||
<!-- You can later replace this with a Flowbite dropdown or Shoelace menu -->
|
||||
</div>
|
||||
</header>
|
||||
@ -1,26 +0,0 @@
|
||||
{# <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="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/shoelace-autoloader.js"></script>
|
||||
<script type="text/javascript" src="{{ url_for('static', path="js/prism.js") }}"></script>
|
||||
|
||||
<script>
|
||||
const sidebarToggle = document.getElementById('sidebar-toggle');
|
||||
const sidebarDrawer = document.getElementById('sidebar');
|
||||
|
||||
sidebarToggle?.addEventListener('click', () => {
|
||||
sidebarDrawer.classList.toggle("hidden");
|
||||
});
|
||||
|
||||
document.body.addEventListener("htmx:afterSwap", (e) => {
|
||||
const swappedEl = e.target;
|
||||
|
||||
const initTargets = swappedEl.querySelectorAll(".flowbite-init-target");
|
||||
if (initTargets.length > 0 && typeof window.initFlowbite === "function") {
|
||||
window.initFlowbite();
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
@ -1,44 +0,0 @@
|
||||
<!-- Sidebar Container -->
|
||||
|
||||
<!-- Top: Brand -->
|
||||
<div class="px-4 py-6">
|
||||
|
||||
<a href="/" class="text-xl font-semibold text-gray-800 dark:text-gray-100">
|
||||
<sl-icon src="{{ url_for('static', path='logo.svg') }}"></sl-icon>
|
||||
Sshecret
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 overflow-y-auto px-4" aria-label="navigation">
|
||||
<ul class="space-y-">
|
||||
|
||||
<li>
|
||||
<a href="/" class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
<sl-icon name="house"></sl-icon>
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="/clients/" class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
<sl-icon name="person-fill-lock"> </sl-icon>
|
||||
Clients
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="/secrets" class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
<sl-icon name="database-lock"></sl-icon>
|
||||
Secrets
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="/audit" class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
<sl-icon name="card-list"></sl-icon>
|
||||
Audit Log
|
||||
</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</nav>
|
||||
@ -1,50 +0,0 @@
|
||||
<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
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('static', path='css/style.css') }}"
|
||||
type="text/css"
|
||||
/>
|
||||
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="(prefers-color-scheme:light)"
|
||||
href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/themes/light.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="(prefers-color-scheme:dark)"
|
||||
href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/themes/dark.css"
|
||||
onload="document.documentElement.classList.add('sl-theme-dark');"
|
||||
/>
|
||||
|
||||
<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>
|
||||
@ -1,71 +0,0 @@
|
||||
{% extends "/base/page.html.j2" %}
|
||||
{% block title %}Change Password{% endblock %}
|
||||
|
||||
{% block page_content %}
|
||||
|
||||
<div class="h-[calc(100vh-8rem)] bg-gray-100 flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-xl p-6 space-y-8 bg-white rounded-lg shadow sm:p-8 dark:bg-gray-800">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Change Password
|
||||
</h2>
|
||||
{% if errors | list %}
|
||||
<div class="flex p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400" role="alert">
|
||||
<svg class="shrink-0 inline w-4 h-4 me-3 mt-[2px]" 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">Danger</span>
|
||||
<div>
|
||||
<span class="font-medium">Error changing password:</span>
|
||||
<ul class="mt-1.5 list-disc list-inside">
|
||||
{% for error in errors %}
|
||||
<li> {{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="mt-8 space-y-6" action="/password" method="POST" id="password-change-form">
|
||||
<input hidden type="text" name="username" value="{{ user.display_name }}" autocomplete="username">
|
||||
<div>
|
||||
<label for="current_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Current password</label>
|
||||
<input type="password" name="current_password" id="current_password" placeholder="••••••••" 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" required autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<div hx-target="this" hx-swap="outerHTML">
|
||||
<div>
|
||||
<label for="password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
placeholder="••••••••"
|
||||
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"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm_password" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
id="confirm-password"
|
||||
placeholder="••••••••"
|
||||
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"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
hx-post="/password/validate-confirm"
|
||||
hx-include="[name='password']"
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Change password</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@ -1,37 +0,0 @@
|
||||
<div hx-target="this" hx-swap="outerHTML">
|
||||
<div>
|
||||
<label for="password" class="block mb-2 text-sm font-medium text-red-900 dark:text-white">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
placeholder="••••••••"
|
||||
value="{{ password }}"
|
||||
class="bg-red-50 border border-red-500 text-red-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-red-700 dark:border-red-600 dark:placeholder-red-400 dark:text-white dark:focus:ring-red-500 dark:focus:border-red-500"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm_password" class="block mb-2 text-sm font-medium text-red-900 dark:text-white">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
id="confirm-password"
|
||||
placeholder="••••••••"
|
||||
class="bg-red-50 border border-red-500 text-red-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-red-700 dark:border-red-600 dark:placeholder-red-400 dark:text-white dark:focus:ring-red-500 dark:focus:border-red-500"
|
||||
required
|
||||
value="{{ confirm_password }}"
|
||||
autocomplete="new-password"
|
||||
hx-post="/password/validate-confirm"
|
||||
hx-include="[name='password']"
|
||||
>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm text-red-600 dark:text-red-500"><span class="font-medium">Oops!</span> Passwords do not match!</p>
|
||||
|
||||
<div class="mt-2">
|
||||
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800 cursor-not-allowed" disabled>Change password</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -1,15 +0,0 @@
|
||||
|
||||
{% extends "/base/page.html.j2" %}
|
||||
|
||||
{% block page_content %}
|
||||
<div class="h-[calc(100vh-8rem)] bg-gray-100 flex items-center justify-center p-4">
|
||||
<div class="text-center xl:max-w-4xl">
|
||||
<h1 class="mb-3 text-2xl font-bold leading-tight text-gray-900 sm:text-4xl lg:text-5xl dark:text-white">Password Changed</h1>
|
||||
<p class="mb-5 text-base font-normal text-gray-500 md:text-lg dark:text-gray-400">Your password was changed sucessfully. Next time you log in, use your new password.</p>
|
||||
<a href="/dashboard" 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 text-center inline-flex items-center mr-3 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
|
||||
<svg class="mr-2 -ml-1 w-5 h-5" 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>
|
||||
Go back to the dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,33 +0,0 @@
|
||||
<div hx-target="this" hx-swap="outerHTML">
|
||||
<div>
|
||||
<label for="password" class="block mb-2 text-sm font-medium text-green-900 dark:text-white">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
placeholder="••••••••"
|
||||
value="{{ password }}"
|
||||
class="bg-green-50 border border-green-500 text-green-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-green-700 dark:border-green-600 dark:placeholder-green-400 dark:text-white dark:focus:ring-green-500 dark:focus:border-green-500"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm_password" class="block mb-2 text-sm font-medium text-green-900 dark:text-white">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
id="confirm-password"
|
||||
placeholder="••••••••"
|
||||
class="bg-green-50 border border-green-500 text-green-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-green-700 dark:border-green-600 dark:placeholder-green-400 dark:text-white dark:focus:ring-green-500 dark:focus:border-green-500"
|
||||
required
|
||||
value="{{ confirm_password }}"
|
||||
autocomplete="new-password"
|
||||
hx-post="/password/validate-confirm"
|
||||
hx-include="[name='password']"
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button type="submit" class="w-full px-5 py-3 text-base font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 sm:w-auto dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">Change password</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,24 +0,0 @@
|
||||
{% extends 'base/master-detail-email.html.j2' %}
|
||||
{% block title %}Client {{ client.name }}{% endblock %}
|
||||
|
||||
{% block master %}
|
||||
{% include '/clients/partials/tree.html.j2' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block detail %}
|
||||
|
||||
<div id="clientdetails" class="w-full">
|
||||
{% include '/clients/partials/client_details.html.j2' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% include '/clients/partials/drawer_create.html.j2' %}
|
||||
{% block local_scripts %}
|
||||
<script>
|
||||
{% include '/clients/partials/tree_event.js' %}
|
||||
</script>
|
||||
{% endblock local_scripts %}
|
||||
|
||||
|
||||
|
||||
@ -1 +0,0 @@
|
||||
<p class="mt-2 text-sm text-red-600 dark:text-red-500"><span class="font-medium">Invalid value. </span> {{explanation}}.</p>
|
||||
@ -1 +0,0 @@
|
||||
<span></span>
|
||||
@ -1,21 +0,0 @@
|
||||
{% extends 'base/master-detail-email.html.j2' %}
|
||||
{% block title %}Clients{% endblock %}
|
||||
|
||||
{% block master %}
|
||||
{% include '/clients/partials/tree.html.j2' %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block detail %}
|
||||
|
||||
<div id="clientdetails" class="w-full bg-white dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-sm italic text-gray-400 dark:text-white">Click an item to view details</h3>
|
||||
</div>
|
||||
{% include '/clients/partials/drawer_create.html.j2' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block local_scripts %}
|
||||
<script>
|
||||
{% include '/clients/partials/tree_event.js' %}
|
||||
</script>
|
||||
{% endblock local_scripts %}
|
||||
@ -1,48 +0,0 @@
|
||||
<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" id="clientListTable">
|
||||
<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>
|
||||
@ -1,180 +0,0 @@
|
||||
<!-- menu -->
|
||||
|
||||
<div class="flowbite-init-target">
|
||||
<div class="flex justify-end px-4">
|
||||
<button id="client-menu-button" data-dropdown-toggle="client-edit-menu" class="inline-block text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-1.5" type="button">
|
||||
<span class="sr-only">Open dropdown</span>
|
||||
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 3">
|
||||
<path d="M2 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm6.041 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM14 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div id="client-edit-menu" class="z-10 hidden text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700">
|
||||
<ul class="py-2" aria-labelledby="client-menu-button">
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
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="block px-4 py-2 text-sm text-gray-900 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
class="block px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
|
||||
hx-delete="/clients/{{client.id}}"
|
||||
hx-target="this"
|
||||
hx-indicator="#client-spinner"
|
||||
hx-confirm="Really delete this client?"
|
||||
>
|
||||
Delete
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<sl-tab-group >
|
||||
<sl-tab slot="nav" panel="client_data">Client Data</sl-tab>
|
||||
<sl-tab slot="nav" panel="events">Events</sl-tab>
|
||||
|
||||
<sl-tab-panel name="client_data">
|
||||
|
||||
<div id="client_details">
|
||||
<div class="w-full p-2">
|
||||
<div class="px-4 sm:px-0">
|
||||
<h3 class="text-base/7 font-semibold text-gray-900 dark:text-gray-50">{{client.name}}</h3>
|
||||
{% if client.description %}
|
||||
<p class="mt-1 max-w-2xl text-sm/6 text-gray-500 dark:text-gray-100">{{ client.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-6 border-t border-gray-100">
|
||||
<dl class="divide-y divide-gray-100">
|
||||
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">Client ID</dt>
|
||||
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">{{client.id}}</dd>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">Client Description</dt>
|
||||
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">{{client.description}}</dd>
|
||||
</div>
|
||||
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">Client Version</dt>
|
||||
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">{{client.version}}</dd>
|
||||
</div>
|
||||
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">Public Key</dt>
|
||||
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300 truncate">{{client.public_key}}</dd>
|
||||
</div>
|
||||
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">Assigned Secrets</dt>
|
||||
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">{{client.secrets|length}}</dd>
|
||||
</div>
|
||||
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
|
||||
<dt class="text-sm/6 font-medium text-gray-900 dark:text-gray-200">Allowed sources</dt>
|
||||
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0 dark:text-gray-300">{{client.policies|join(', ')}}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sl-tab-panel>
|
||||
<sl-tab-panel name="events">
|
||||
|
||||
<table class="min-w-full lg:table-fixed divide-y divide-gray-200 dark:divide-gray-600" id="last-audit-events">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Timestamp</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Subsystem</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Message</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Origin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800">
|
||||
{% for entry in events.results | list %}
|
||||
<tr class="{{ loop.cycle('', 'bg-gray-50 dark:bg-gray-700 ') }}hover:bg-gray-100 dark:hover:bg-gray-700" id="login-entry-{{ entry.id }}">
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
<p>{{ entry.timestamp }}<button data-popover-target="popover-audit-entry-{{ entry.id }}" data-popover-placement="bottom-end" type="button"><svg class="w-4 h-4 ms-2 text-gray-400 hover:text-gray-500" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"></path>
|
||||
</svg><span class="sr-only">Show information</span></button></p>
|
||||
|
||||
<div data-popover id="popover-audit-entry-{{entry.id}}" role="tooltip" class="absolute z-10 invisible inline-block text-sm text-gray-500 transition-opacity duration-300 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 w-80 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
|
||||
<dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700 px-2 py-2">
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">ID</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.id }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Subsystem</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.subsystem }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Timestamp</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.timestamp }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Operation</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.operation }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client ID</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.client_id }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client Name</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.client_name }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret ID</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.secret_id }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret Name</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.secret_name }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Message</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.message }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Origin</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.origin }}</dd>
|
||||
</div>
|
||||
{% if entry.data %}
|
||||
{% for key, value in entry.data.items() %}
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">{{ key | capitalize }}</dt>
|
||||
<dd class="text-xs font-semibold">{{ value }}</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.subsystem }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.message }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.origin }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</sl-tab-panel>
|
||||
</sl-tab-group>
|
||||
</div>
|
||||
|
||||
{% include '/clients/partials/drawer_edit.html.j2' %}
|
||||
@ -1,164 +0,0 @@
|
||||
<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 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>
|
||||
<div
|
||||
class="htmx-indicator mb-6"
|
||||
id="client-create-spinner">
|
||||
<div role="status">
|
||||
<svg aria-hidden="true" class="w-4 h-4 me-2 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>
|
||||
</div>
|
||||
|
||||
<form
|
||||
hx-post="/clients/"
|
||||
>
|
||||
<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"
|
||||
value="0.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="mt-2 text-sm text-red-600 dark:text-red-500"
|
||||
id="client-create-error"
|
||||
>
|
||||
</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>
|
||||
@ -1,183 +0,0 @@
|
||||
<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 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
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>
|
||||
<div
|
||||
class="htmx-indicator mb-6"
|
||||
id="client-update-spinner">
|
||||
<div role="status">
|
||||
<svg aria-hidden="true" class="w-4 h-4 me-2 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>
|
||||
</div>
|
||||
|
||||
<form
|
||||
hx-put="/clients/{{ client.id }}"
|
||||
hx-target="#clientdetails"
|
||||
hx-indicator="#client-update-spinner"
|
||||
>
|
||||
<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, ::/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 this will create a new version of the client, and any existing secrets will no longer be accessible.
|
||||
</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="mt-2 text-sm text-red-600 dark:text-red-500"
|
||||
id="client-update-error"
|
||||
>
|
||||
</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-indicator="#client-update-spinner"
|
||||
hx-confirm="Are you sure?"
|
||||
hx-target="#client-update-error"
|
||||
id="delete-button-{{ client.id }}"
|
||||
>
|
||||
<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>
|
||||
@ -1,73 +0,0 @@
|
||||
{% macro display_page(num) %}
|
||||
<li>
|
||||
<button
|
||||
hx-get="/clients/page/{{num}}"
|
||||
hx-target="#client-tree"
|
||||
hx-push-url="true"
|
||||
type="button"
|
||||
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||
{{ num }}
|
||||
</button>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro display_current_page(num) %}
|
||||
<li>
|
||||
<button type="button" aria-current="page" class="z-10 flex items-center justify-center px-3 h-8 leading-tight text-blue-600 border border-blue-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white">
|
||||
{{ num }}
|
||||
</button>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
<div class="inline-flex mt-2 xs:mt-0">
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="flex items-center -space-x-px h-8 text-sm">
|
||||
<li>
|
||||
{% if pages.is_first %}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-100 bg-white border border-e-0 border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-200 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||
{% else %}
|
||||
<button
|
||||
type="button"
|
||||
hx-get="/clients/page/{{pages.page - 1}}"
|
||||
hx-target="#client-tree"
|
||||
hx-push-url="true"
|
||||
|
||||
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-e-0 border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||
{% endif %}
|
||||
<span class="sr-only">Previous</span>
|
||||
<svg class="w-2.5 h-2.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
{% for p in pages.pages %}
|
||||
{% if p == pages.page %}
|
||||
{{ display_current_page(p) }}
|
||||
{% else %}
|
||||
{{ display_page(p) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li>
|
||||
{% if pages.is_last %}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-100 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||
{% else %}
|
||||
<button
|
||||
hx-get="/clients/page/{{pages.page + 1}}"
|
||||
hx-target="#client-tree"
|
||||
hx-push-url="true"
|
||||
type="button"
|
||||
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">
|
||||
{% endif %}
|
||||
<span class="sr-only">Next</span>
|
||||
<svg class="w-2.5 h-2.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4"/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
@ -1,64 +0,0 @@
|
||||
{# This is the master block #}
|
||||
|
||||
<div class="flowbite-init-target">
|
||||
<div class="tree-header grid grid-cols-2 place-content-between mb-6">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Client List</h1>
|
||||
<div class="flex">
|
||||
<div
|
||||
class="htmx-indicator mt-2"
|
||||
id="client-spinner">
|
||||
<div role="status">
|
||||
<svg aria-hidden="true" class="inline w-6 h-6 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>
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full justify-end"
|
||||
>
|
||||
<sl-icon-button
|
||||
name="plus-square"
|
||||
label="Add Client"
|
||||
data-drawer-target="drawer-create-client-default"
|
||||
data-drawer-show="drawer-create-client-default"
|
||||
aria-controls="drawer-create-client-default"
|
||||
data-drawer-placement="right"
|
||||
|
||||
|
||||
></sl-icon-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-full">
|
||||
<div class="relative">
|
||||
<div class="border-b border-gray-200 py-2 mb-6">
|
||||
<label for="default-search" class="mb-2 text-sm font-medium text-gray-900 sr-only dark:text-white">Search</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-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="search"
|
||||
id="client-search"
|
||||
name="query"
|
||||
class="block w-full p-2.5 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-gray-900 focus:border-gray-900 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-gray-900 dark:focus:border-gray-900"
|
||||
placeholder="Search..."
|
||||
required
|
||||
hx-post="/clients/query"
|
||||
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
|
||||
hx-target="#client-tree-items"
|
||||
hx-indicator="#client-spinner"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="client-tree-items">
|
||||
{% include '/clients/partials/tree_items.html.j2' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
function setBreadcrumb(name) {
|
||||
// Set the current client name as the final breadcrumb
|
||||
const breadcrumbs = document.getElementById("breadcrumbs");
|
||||
const existingNode = document.getElementById("bc-dynamic-client");
|
||||
if (existingNode) {
|
||||
breadcrumbs.removeChild(existingNode);
|
||||
}
|
||||
const newCrumb = document.createElement("sl-breadcrumb-item");
|
||||
newCrumb.setAttribute("id", "bc-dynamic-client");
|
||||
const bcTitle = document.createTextNode(name);
|
||||
newCrumb.appendChild(bcTitle);
|
||||
breadcrumbs.appendChild(newCrumb);
|
||||
}
|
||||
function addTreeListener() {
|
||||
const tree = document.querySelector("sl-tree");
|
||||
|
||||
if (!tree) return;
|
||||
|
||||
tree.addEventListener("sl-selection-change", (event) => {
|
||||
const selectedEl = event.detail.selection[0];
|
||||
|
||||
if (!selectedEl) return;
|
||||
|
||||
const masterPane = document.getElementById("master-pane");
|
||||
const detailPane = document.getElementById("detail-pane");
|
||||
const type = selectedEl.dataset.nodeType;
|
||||
const clientId = selectedEl.dataset.clientId;
|
||||
const name = selectedEl.dataset.clientName;
|
||||
//console.log(`Event on ${type} ${name} ${clientId}`);
|
||||
|
||||
if (!type || !clientId) return;
|
||||
|
||||
let url = `/clients/client/${encodeURIComponent(clientId)}`;
|
||||
if (url) {
|
||||
htmx
|
||||
.ajax("GET", url, {
|
||||
target: "#clientdetails",
|
||||
//swap: 'OuterHTML',
|
||||
indicator: "#client-spinner",
|
||||
})
|
||||
.then(() => {
|
||||
masterPane.classList.add("hidden");
|
||||
detailPane.classList.remove("hidden");
|
||||
setBreadcrumb(name);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
addTreeListener();
|
||||
});
|
||||
|
||||
document.addEventListener("htmx:afterSwap", () => {
|
||||
addTreeListener();
|
||||
});
|
||||
@ -1,41 +0,0 @@
|
||||
<div class="flowbite-init-target">
|
||||
{% if more_results %}
|
||||
<span class="text-gray-400 text-xs italic mt-4">{{more_results}} more results. Narrow search to show them...</span>
|
||||
{% endif %}
|
||||
<sl-tree class="full-height-tree">
|
||||
{% for item in clients %}
|
||||
<sl-tree-item
|
||||
id="client-{{ item.id }}"
|
||||
data-node-type="client"
|
||||
data-client-id="{{ item.id }}"
|
||||
data-client-name="{{ item.name }}"
|
||||
{% if client and client.id == item.id %}
|
||||
selected
|
||||
{% endif %}
|
||||
|
||||
>
|
||||
<sl-icon name="person-fill-lock"> </sl-icon>
|
||||
<span class="px-2">{{item.name}}</span>
|
||||
{% for secret in item.secrets %}
|
||||
<sl-tree-item
|
||||
id="client-{{ item.name }}-secret-{{ secret }}"
|
||||
data-node-type="secret"
|
||||
data-secret-client-name="{{ item.name }}"
|
||||
data-secret-name="{{ secret }}"
|
||||
>
|
||||
<sl-icon name="file-lock2"> </sl-icon>
|
||||
<span class="px-2">{{ secret }}</span>
|
||||
</sl-tree-item>
|
||||
{% endfor %}
|
||||
</sl-tree-item>
|
||||
{% endfor %}
|
||||
</sl-tree>
|
||||
{% if pages %}
|
||||
<div class="mt-4 text-center flex items-center flex-col border-t border-gray-100">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-400">
|
||||
Showing <span class="font-semibold text-gray-900 dark:text-white">{{ pages.offset }}</span> to <span class="font-semibold text-gray-900 dark:text-white">{{ pages.total_pages }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ results.total_results }}</span> Entries
|
||||
</span>
|
||||
{% include 'clients/partials/pagination.html.j2' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -1,488 +0,0 @@
|
||||
{% extends "/base/base.html.j2" %} {% block content %}
|
||||
|
||||
<div class="px-4 pt-6">
|
||||
<div class="py-8 px-4 mt-4 mx-auto max-w-screen-xl text-center lg:py-16">
|
||||
<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>
|
||||
|
||||
<div
|
||||
class="grid w-full grid-cols-1 gap-4 mt-4 xl:grid-cols-2 2xl:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
class="items-center justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800"
|
||||
id="dashboard-stats-panel"
|
||||
>
|
||||
<div class="w-full">
|
||||
<h3 class="text-base text-gray-500 dark:text-gray-400">Stats</h3>
|
||||
<dl
|
||||
class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700"
|
||||
>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 text-xs dark:text-gray-400">
|
||||
Clients
|
||||
</dt>
|
||||
<dd class="text-lg font-semibold" id="stats-client-count">
|
||||
{{ stats.clients }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col py-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">
|
||||
Secrets
|
||||
</dt>
|
||||
<dd class="text-lg font-semibold" id="stats-secret-count">
|
||||
{{ stats.secrets }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col py-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">
|
||||
Audit Events
|
||||
</dt>
|
||||
<dd class="text-lg font-semibold" id="stats-audit-count">
|
||||
{{ stats.audit_events }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="items-center 2xl: col-span-2 xl:col-span-2 justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800"
|
||||
>
|
||||
<div class="w-full">
|
||||
<h3 class="text-base font-normal text-gray-500 dark:text-gray-400">
|
||||
Last Login Events
|
||||
</h3>
|
||||
{% if last_login_events.total > 0 %}
|
||||
<table
|
||||
class="min-w-full divide-y divide-gray-200 dark:divide-gray-600"
|
||||
id="last-login-events"
|
||||
>
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white"
|
||||
>
|
||||
Timestamp
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white"
|
||||
>
|
||||
Subsystem
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white"
|
||||
>
|
||||
Client/Username
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white"
|
||||
>
|
||||
Origin
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800">
|
||||
{% for entry in last_login_events.results | list %}
|
||||
<tr
|
||||
class="{{ loop.cycle('', 'bg-gray-50 dark:bg-gray-700 ') }}hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
id="login-entry-{{ entry.id }}"
|
||||
>
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white"
|
||||
>
|
||||
<p>
|
||||
{{ entry.timestamp }}<button
|
||||
data-popover-target="popover-login-entry-{{ entry.id }}"
|
||||
data-popover-placement="bottom-end"
|
||||
type="button"
|
||||
id="btn-popover-login-entry-{{ entry.id }}"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 ms-2 text-gray-400 hover:text-gray-500"
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
|
||||
clip-rule="evenodd"
|
||||
></path></svg
|
||||
><span class="sr-only">Show information</span>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<div
|
||||
data-popover
|
||||
id="popover-login-entry-{{entry.id}}"
|
||||
role="tooltip"
|
||||
class="absolute z-10 invisible inline-block text-sm text-gray-500 transition-opacity duration-300 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 w-80 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 popover-login-entry"
|
||||
>
|
||||
<dl
|
||||
class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700 px-2 py-2"
|
||||
>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
ID
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.id }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Subsystem
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">
|
||||
{{ entry.subsystem }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Timestamp
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">
|
||||
{{ entry.timestamp }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Operation
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">
|
||||
{{ entry.operation }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Client ID
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">
|
||||
{{ entry.client_id }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Client Name
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">
|
||||
{{ entry.client_name }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Secret ID
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">
|
||||
{{ entry.secret_id }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Secret Name
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">
|
||||
{{ entry.secret_name }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Message
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.message }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Origin
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.origin }}</dd>
|
||||
</div>
|
||||
{% if entry.data %} {% for key, value in entry.data.items()
|
||||
%}
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
{{ key | capitalize }}
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">{{ value }}</dd>
|
||||
</div>
|
||||
{% endfor %} {% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white"
|
||||
>
|
||||
{{ entry.subsystem }}
|
||||
</td>
|
||||
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white"
|
||||
>
|
||||
{% if entry.client_name %} {{ entry.client_name }} {% elif
|
||||
entry.data.username %} {{ entry.data.username }} {% endif %}
|
||||
</td>
|
||||
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white"
|
||||
>
|
||||
{{ entry.origin }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-sm italic">No entries</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="items-center 2xl:col-span-3 xl:col-span-3 justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800"
|
||||
>
|
||||
<div class="w-full">
|
||||
<h3 class="text-base font-normal text-gray-500 dark:text-gray-400">
|
||||
Last Audit Events
|
||||
</h3>
|
||||
{% if last_audit_events.total > 0 %}
|
||||
<table
|
||||
class="min-w-full divide-y divide-gray-200 dark:divide-gray-600"
|
||||
id="last-audit-events"
|
||||
>
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white"
|
||||
>
|
||||
Timestamp
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white"
|
||||
>
|
||||
Subsystem
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white"
|
||||
>
|
||||
Message
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white"
|
||||
>
|
||||
Origin
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800">
|
||||
{% for entry in last_audit_events.results | list %}
|
||||
<tr
|
||||
class="{{ loop.cycle('', 'bg-gray-50 dark:bg-gray-700 ') }}hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
id="login-entry-{{ entry.id }}"
|
||||
>
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white"
|
||||
>
|
||||
<p>
|
||||
{{ entry.timestamp }}<button
|
||||
data-popover-target="popover-audit-entry-{{ entry.id }}"
|
||||
data-popover-placement="bottom-end"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 ms-2 text-gray-400 hover:text-gray-500"
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
|
||||
clip-rule="evenodd"
|
||||
></path></svg
|
||||
><span class="sr-only">Show information</span>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<div
|
||||
data-popover
|
||||
id="popover-audit-entry-{{entry.id}}"
|
||||
role="tooltip"
|
||||
class="absolute z-10 invisible inline-block text-sm text-gray-500 transition-opacity duration-300 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 w-80 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<dl
|
||||
class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700 px-2 py-2"
|
||||
>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
ID
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.id }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Subsystem
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">
|
||||
{{ entry.subsystem }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Timestamp
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">
|
||||
{{ entry.timestamp }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Operation
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">
|
||||
{{ entry.operation }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Client ID
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">
|
||||
{{ entry.client_id }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Client Name
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">
|
||||
{{ entry.client_name }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Secret ID
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">
|
||||
{{ entry.secret_id }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Secret Name
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">
|
||||
{{ entry.secret_name }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Message
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.message }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
Origin
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.origin }}</dd>
|
||||
</div>
|
||||
{% if entry.data %} {% for key, value in entry.data.items()
|
||||
%}
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt
|
||||
class="mb-1 text-gray-500 md:text-xs dark:text-gray-400"
|
||||
>
|
||||
{{ key | capitalize }}
|
||||
</dt>
|
||||
<dd class="text-xs font-semibold">{{ value }}</dd>
|
||||
</div>
|
||||
{% endfor %} {% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white"
|
||||
>
|
||||
{{ entry.subsystem }}
|
||||
</td>
|
||||
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white"
|
||||
>
|
||||
{{ entry.message }}
|
||||
</td>
|
||||
|
||||
<td
|
||||
class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white"
|
||||
>
|
||||
{{ entry.origin }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-sm italic">No entries</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@ -1,28 +0,0 @@
|
||||
<!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 flex flex-col md:flex-row flex-grow">
|
||||
<main>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% include '/dashboard/_scripts.html' %}
|
||||
{% block scripts %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@ -1 +0,0 @@
|
||||
<!-- todo -->
|
||||
@ -1,21 +0,0 @@
|
||||
<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>
|
||||
@ -1,18 +0,0 @@
|
||||
<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="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/shoelace-autoloader.js"></script>
|
||||
<script type="text/javascript" src="{{ url_for('static', path="js/prism.js") }}"></script>
|
||||
|
||||
<script>
|
||||
document.body.addEventListener("htmx:afterSwap", (e) => {
|
||||
const swappedEl = e.target;
|
||||
|
||||
const initTargets = swappedEl.querySelectorAll(".flowbite-init-target");
|
||||
if (initTargets.length > 0 && typeof window.initFlowbite === "function") {
|
||||
window.initFlowbite();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -1,37 +0,0 @@
|
||||
<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
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('static', path='css/style.css') }}"
|
||||
type="text/css"
|
||||
/>
|
||||
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="(prefers-color-scheme:light)"
|
||||
href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/themes/light.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="(prefers-color-scheme:dark)"
|
||||
href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.20.1/cdn/themes/dark.css"
|
||||
onload="document.documentElement.classList.add('sl-theme-dark');"
|
||||
/>
|
||||
@ -1,38 +0,0 @@
|
||||
<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="none">
|
||||
{% include '/clients/drawer_client_create_inner.html.j2' %}
|
||||
</form>
|
||||
</div>
|
||||
@ -1,38 +0,0 @@
|
||||
<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="none">
|
||||
{% include '/secrets/drawer_secret_create_inner.html.j2' %}
|
||||
</form>
|
||||
</div>
|
||||
@ -1,102 +0,0 @@
|
||||
<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>
|
||||
<sl-avatar label="User avatar"></sl-avatar>
|
||||
</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.display_name }}
|
||||
</p>
|
||||
</div>
|
||||
<ul class="py-1" role="none">
|
||||
{% if user.local %}
|
||||
<li>
|
||||
<a
|
||||
href="/password"
|
||||
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>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a
|
||||
href="/logout"
|
||||
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>
|
||||
@ -1,112 +0,0 @@
|
||||
<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>
|
||||
@ -1,71 +0,0 @@
|
||||
{% 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 %}
|
||||
@ -1,3 +0,0 @@
|
||||
<p class="mt-2 text-sm text-green-600 dark:text-red-500">
|
||||
<span class="font-medium">{{ message }}</span>
|
||||
</p>
|
||||
@ -1,3 +0,0 @@
|
||||
<p class="mt-2 text-sm text-green-600 dark:text-green-500">
|
||||
<span class="font-medium">{{ message }}</span>
|
||||
</p>
|
||||
@ -1,92 +0,0 @@
|
||||
{% extends "/base/bare.html.j2" %} {% 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"
|
||||
autocomplete="username"
|
||||
required=""
|
||||
/>
|
||||
</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="••••••••"
|
||||
autocomplete="current-password"
|
||||
required=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2.5 rounded-lg transition-colors"
|
||||
type="submit"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
{% if oidc.enabled %}
|
||||
<div class="w-full items-center text-center my-4 flex">
|
||||
<div
|
||||
class="w-full h-[0.125rem] box-border bg-gray-200 dark:bg-gray-700"
|
||||
></div>
|
||||
<div
|
||||
class="px-4 text-lg text-sm font-medium text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Or
|
||||
</div>
|
||||
<div
|
||||
class="w-full h-[0.125rem] box-border bg-gray-200 dark:bg-gray-700"
|
||||
></div>
|
||||
</div>
|
||||
<div class="w-full text-center my-4">
|
||||
<a href="/oidc/login">
|
||||
<button
|
||||
class="w-full bg-white hover:bg-gray-100 text-gray-900 border border-gray-300 transition-colors font-medium py-2.5 rounded-lg dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
Sign in with {{ oidc.provider_name }}
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %} {% endblock %}
|
||||
</div>
|
||||
@ -1,113 +0,0 @@
|
||||
{% macro display_entry(entry) %}
|
||||
<sl-tree-item
|
||||
id="entry_{{ entry.name }}"
|
||||
class="tree-entry-item"
|
||||
data-type="entry"
|
||||
data-name="{{ entry.name }}"
|
||||
data-group-path="/"
|
||||
{% if secret | default(false) %}
|
||||
{% if secret.name == entry.name %}
|
||||
selected=""
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
>
|
||||
<sl-icon name="shield"> </sl-icon>
|
||||
<span class="px-2">{{ entry.name }}</span>
|
||||
</sl-tree-item>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro display_group(group) %}
|
||||
<sl-tree-item
|
||||
class="secret-group-list-item"
|
||||
data-type="group"
|
||||
data-name="{{ group.group_name }}"
|
||||
data-group-path="{{ group.path }}"
|
||||
{% if group_path_nodes | default(false) %}
|
||||
{% if group.group_name in group_path_nodes %}
|
||||
expanded=""
|
||||
{% endif %}
|
||||
{% if selected_group | default(None) %}
|
||||
{% if group.path == selected_group %}
|
||||
selected=""
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
>
|
||||
<sl-icon name="folder"> </sl-icon>
|
||||
<span class="px-2">{{ group.group_name }}</span>
|
||||
{% for entry in group.entries %}
|
||||
{{ display_entry(entry) }}
|
||||
{% endfor %}
|
||||
{% for child in group.children %}
|
||||
{{ display_group(child) }}
|
||||
{% endfor %}
|
||||
</sl-tree-item>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% extends 'base/master-detail-email.html.j2' %}
|
||||
|
||||
{% block title %}Secrets{% endblock %}
|
||||
|
||||
|
||||
{% block master %}
|
||||
|
||||
<div class="flowbite-init-target">
|
||||
<div id="secret-tree">
|
||||
<sl-tree class="tree-with-icons">
|
||||
<sl-tree-item
|
||||
id="secret-group-root-item"
|
||||
data-type="root"
|
||||
data-name="root"
|
||||
|
||||
{% if "/" in group_path_nodes %}
|
||||
expanded=""
|
||||
{% endif %}
|
||||
{% if selected_group == "/"%}
|
||||
selected=""
|
||||
{% endif %}
|
||||
>
|
||||
<sl-icon name="folder"> </sl-icon>
|
||||
<span class="px-2">Ungrouped</span>
|
||||
{% for entry in groups.ungrouped %}
|
||||
{{ display_entry(entry) }}
|
||||
{% endfor %}
|
||||
</sl-tree-item>
|
||||
{% for child in groups.groups %}
|
||||
{{ display_group(child) }}
|
||||
{% endfor %}
|
||||
</sl-tree>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block detail %}
|
||||
{% if group_page | default(false) %}
|
||||
<div class="w-full" id="secretdetails">
|
||||
{% include '/secrets/partials/group_detail.html.j2' %}
|
||||
</div>
|
||||
{% elif root_group_page | default(false) %}
|
||||
<div class="w-full" id="secretdetails">
|
||||
{% include '/secrets/partials/edit_root.html.j2' %}
|
||||
</div>
|
||||
{% elif secret_page | default(false) %}
|
||||
<div class="w-full" id="secretdetails">
|
||||
{% include '/secrets/partials/tree_detail.html.j2' %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% include '/secrets/partials/default_detail.html.j2' %}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block local_scripts %}
|
||||
<script>
|
||||
{% include '/secrets/partials/tree_event.js' %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
<form
|
||||
hx-post="/secrets/{{secret.name}}/clients/"
|
||||
hx-target="#secretclientdetails"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<label for="client" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Client</label>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<sl-select label="Select client" name="client">
|
||||
{% for client in clients %}
|
||||
<sl-option value="{{ client.id }}">{{ client.name }}</sl-option>
|
||||
{% endfor %}
|
||||
</sl-select>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<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 to Secret
|
||||
</button>
|
||||
</div>
|
||||
@ -1,7 +0,0 @@
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-900 hover:text-blue-700 border border-gray-200 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:hover:bg-blue-500 dark:focus:ring-blue-800"
|
||||
hx-get="/secrets/{{secret.name}}/clients/"
|
||||
hx-target="#secretclientaction"
|
||||
>Assign to new client
|
||||
</button>
|
||||
@ -1,19 +0,0 @@
|
||||
{% for client in secret.clients %}
|
||||
<li class="w-full px-4 py-2">
|
||||
<span class="inline-flex items-center px-2 py-1 me-2 text-sm font-medium text-blue-800 bg-blue-100 rounded-sm dark:bg-blue-900 dark:text-blue-300">
|
||||
{{ client }}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center p-1 ms-2 text-sm text-blue-400 bg-transparent rounded-xs hover:bg-blue-200 hover:text-blue-900 dark:hover:bg-blue-800 dark:hover:text-blue-300"
|
||||
hx-delete="/secrets/{{ secret.name }}/clients/{{ client }}"
|
||||
hx-target="#secretclientlist"
|
||||
hx-confirm="Remove client {{ client }} from secret?"
|
||||
aria-label="Remove">
|
||||
<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 client</span>
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
@ -1,10 +0,0 @@
|
||||
<div class="w-full my-2 dark:text-white">
|
||||
<ul class="w-48 text-sm font-medium text-gray-900 bg-white dark:bg-gray-700 dark:text-white" id="secretclientlist">
|
||||
{% include '/secrets/partials/client_list_inner.html.j2' %}
|
||||
</ul>
|
||||
</div>
|
||||
{% if secret.secret %}
|
||||
<div class="w-full my-2" id="secretclientaction">
|
||||
{% include '/secrets/partials/client_assign_button.html.j2' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -1,94 +0,0 @@
|
||||
<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" id="autoGenerateCheckboxLabel">
|
||||
<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
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,6 +0,0 @@
|
||||
<div class="w-full" id="secretdetails">
|
||||
<h3 class="mb-4 text-sm italic text-gray-400 dark:text-white">Click an item to view details</h3>
|
||||
<div class="htmx-indicator secret-spinner">
|
||||
{% include '/secrets/partials/skeleton.html.j2' %}
|
||||
</div>
|
||||
</div>
|
||||
@ -1,53 +0,0 @@
|
||||
<div class="w-full dark:text-white">
|
||||
<sl-details summary="Create secret">
|
||||
<form
|
||||
hx-post="/secrets/create/root"
|
||||
hx-target="#secretdetails"
|
||||
hx-swap="OuterHTML"
|
||||
>
|
||||
{% include '/secrets/partials/create_secret.html.j2' %}
|
||||
</form>
|
||||
</sl-details>
|
||||
<sl-details summary="Create group">
|
||||
|
||||
<form
|
||||
hx-post="/secrets/group/"
|
||||
hx-target="#secretdetails"
|
||||
hx-swap="OuterHTML"
|
||||
hx-indicator=".secret-spinner"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<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="Group name"
|
||||
required=""
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<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="Description"
|
||||
/>
|
||||
</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"
|
||||
>
|
||||
Add Group
|
||||
</button>
|
||||
</form>
|
||||
</sl-details>
|
||||
|
||||
</div>
|
||||
@ -1,129 +0,0 @@
|
||||
<div class="w-full dark:text-white">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xl font-semibold dark:text-white">Group {{group.group_name}}</h3>
|
||||
{% if description %}
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ group.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<sl-details summary="Create secret">
|
||||
<form
|
||||
hx-post="/secrets/create/group/{{ group.group_name }}"
|
||||
hx-target="#secretdetails"
|
||||
hx-swap="OuterHTML"
|
||||
>
|
||||
{% include '/secrets/partials/create_secret.html.j2' %}
|
||||
</form>
|
||||
</sl-details>
|
||||
<sl-details summary="Create nested group">
|
||||
|
||||
<form
|
||||
hx-post="/secrets/group/"
|
||||
hx-target="#secretdetails"
|
||||
hx-swap="OuterHTML"
|
||||
hx-indicator=".secret-spinner"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<label for="name" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<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="Group name"
|
||||
required=""
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<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="Description"
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="parent_group" value="{{ group.group_name }}" />
|
||||
<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 Group
|
||||
</button>
|
||||
</form>
|
||||
</sl-details>
|
||||
<sl-details summary="Edit group">
|
||||
<form
|
||||
hx-put="/secrets/partial/group/{{group.group_name}}/description"
|
||||
hx-target="#secretdetails"
|
||||
hx-swap="OuterHTML"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<label
|
||||
for="description"
|
||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
||||
>Description</label
|
||||
>
|
||||
</div>
|
||||
<div class="flex w-full">
|
||||
<div class="relative w-full">
|
||||
<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"
|
||||
value="{{ group.description }}"
|
||||
required=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="px-2.5 mb-2">
|
||||
<button type="Submit" class="text-gray-900 hover:text-blue-700 border border-gray-200 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:hover:bg-blue-500 dark:focus:ring-blue-800">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-5">
|
||||
<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/group/{{ group.group_name }}"
|
||||
hx-target="#secretdetails"
|
||||
hx-swap="OuterHTML"
|
||||
hx-confirm="Deleting a group will move all its secrets to the Ungrouped category. Continue?"
|
||||
>
|
||||
<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 group
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</sl-details>
|
||||
|
||||
<div class="htmx-indicator secret-spinner">
|
||||
<div role="status">
|
||||
<svg aria-hidden="true" class="inline w-6 h-6 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>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
@ -1,7 +0,0 @@
|
||||
<div class="w-full" id="secretdetails">
|
||||
<a
|
||||
href="{{ destination }}"
|
||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline">
|
||||
Redirecting...
|
||||
</a>
|
||||
</div>
|
||||
@ -1,18 +0,0 @@
|
||||
<form hx-put="/secrets/partial/secret/{{ secret.name }}/value" hx-indicator="#secretupdatespinner">
|
||||
<div class="mb-6">
|
||||
<label for="secret_value" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Value</label>
|
||||
</div>
|
||||
<div class="flex w-full">
|
||||
<div class="relative w-full">
|
||||
<input type="text" name="secret_value" aria-label="secret-value" class="mb-6 bg-gray-100 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-400 dark:focus:ring-blue-500 dark:focus:border-blue-500" value="{{ secret.secret }}">
|
||||
</div>
|
||||
<div class="px-2.5 mb-2">
|
||||
<button type="submit" class="bg-primary-700 text-white 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 me-2 mb-2 dark-bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if updated %}
|
||||
<p class="text-sm text-green-600 dark:text-green-500">Secret updated.</p>
|
||||
{% endif %}
|
||||
</form>
|
||||
@ -1,38 +0,0 @@
|
||||
<div role="status" class="w-full p-4 space-y-4 border border-gray-200 divide-y divide-gray-200 rounded-sm shadow-sm animate-pulse dark:divide-gray-700 md:p-6 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5"></div>
|
||||
<div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12"></div>
|
||||
</div>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
@ -1,203 +0,0 @@
|
||||
<div class="w-full flowbite-init-target dark:text-white" id="secretdetails">
|
||||
|
||||
<!-- menu -->
|
||||
|
||||
<div class="flex justify-end px-4">
|
||||
<button id="secret-menu-button" data-dropdown-toggle="secret-edit-menu" class="inline-block text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-1.5" type="button">
|
||||
<span class="sr-only">Open dropdown</span>
|
||||
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 3">
|
||||
<path d="M2 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm6.041 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM14 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Dropdown menu -->
|
||||
<div id="secret-edit-menu" class="z-10 hidden text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700">
|
||||
<ul class="py-2" aria-labelledby="secret-menu-button">
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
class="block px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
|
||||
hx-delete="/secrets/{{secret.name}}"
|
||||
hx-target="#secretdetails"
|
||||
hx-swap="OuterHTML"
|
||||
hx-indicator=".secret-spinner"
|
||||
hx-confirm="Really delete this secret?"
|
||||
>
|
||||
Delete
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<h3 class="mb-4 text-xl font-semibold dark:text-white">{{secret.name}}</h3>
|
||||
{% if secret.description %}
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ secret.description }}</span>
|
||||
{% endif %}
|
||||
{% if not secret.secret %}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 italic">This secret was created outside of sshecret-admin. It cannot be decrypted, and therefore fewer options are available here.</p>
|
||||
{% endif %}
|
||||
<div class="htmx-indicator secret-spinner">
|
||||
<div role="status">
|
||||
<svg aria-hidden="true" class="inline w-6 h-6 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>
|
||||
</div>
|
||||
|
||||
<sl-details summary="Clients" open>
|
||||
<div id="secretclientdetails">
|
||||
{% include '/secrets/partials/client_secret_details.html.j2' %}
|
||||
</div>
|
||||
</sl-details>
|
||||
{% if secret.secret %}
|
||||
<sl-details summary="Read/Update Secret">
|
||||
<div id="secretvalue">
|
||||
<div class="mb-6">
|
||||
<label for="secret-value" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Value</label>
|
||||
</div>
|
||||
<div class="flex w-full">
|
||||
<div class="relative w-full">
|
||||
<input type="text" id="disabled-input" aria-label="disabled input" class="mb-6 bg-gray-100 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 cursor-not-allowed dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-400 dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="••••••••" disabled>
|
||||
</div>
|
||||
<div class="px-2.5 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-900 hover:text-blue-700 border border-gray-200 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:hover:bg-blue-500 dark:focus:ring-blue-800"
|
||||
hx-get="/secrets/partial/{{ secret.name }}/viewsecret"
|
||||
hx-target="#secretvalue"
|
||||
hx-trigger="click"
|
||||
hx-indicator="#secretupdatespinner"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="htmx-indicator" id="secretupdatespinner">
|
||||
<div role="status">
|
||||
<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>
|
||||
</div>
|
||||
</sl-details>
|
||||
{% if groups.groups %}
|
||||
<sl-details summary="Group">
|
||||
<form
|
||||
hx-put="/secrets/set-group/{{ secret.name }}"
|
||||
hx-target="#secretdetails"
|
||||
hx-swap="OuterHTML"
|
||||
hx-indicator=".secret-spinner"
|
||||
>
|
||||
<div class="flex w-full">
|
||||
<div class="relative w-full">
|
||||
<select id="group_name" name="group_name" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-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-blue-500 dark:focus:border-blue-500">
|
||||
<option value="__ROOT">Ungrouped</option>
|
||||
{% for group in groups.groups %}
|
||||
<option value="{{ group.group_name }}" {% if group.name == secret.group -%}selected{% endif %}>{{ group.path }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="px-2.5 mb-2">
|
||||
<button type="Submit" class="text-gray-900 hover:text-blue-700 border border-gray-200 hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 mb-2 dark:border-blue-500 dark:text-blue-500 dark:hover:text-white dark:hover:bg-blue-500 dark:focus:ring-blue-800">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</sl-details>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<sl-details summary="Events" class="dark:text-white">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-600" id="last-audit-events">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Timestamp</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Subsystem</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Message</th>
|
||||
<th scope="col" class="p-4 text-xs font-medium tracking-wider text-left text-gray-500 uppercase dark:text-white">Origin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800">
|
||||
{% for entry in events.results | list %}
|
||||
<tr class="{{ loop.cycle('', 'bg-gray-50 dark:bg-gray-700 ') }}hover:bg-gray-100 dark:hover:bg-gray-700" id="login-entry-{{ entry.id }}">
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
<p>{{ entry.timestamp }}<button data-popover-target="popover-audit-entry-{{ entry.id }}" data-popover-placement="bottom-end" type="button"><svg class="w-4 h-4 ms-2 text-gray-400 hover:text-gray-500" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"></path>
|
||||
</svg><span class="sr-only">Show information</span></button></p>
|
||||
|
||||
<div data-popover id="popover-audit-entry-{{entry.id}}" role="tooltip" class="absolute z-10 invisible inline-block text-sm text-gray-500 transition-opacity duration-300 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 w-80 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400">
|
||||
<dl class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700 px-2 py-2">
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">ID</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.id }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Subsystem</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.subsystem }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Timestamp</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.timestamp }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Operation</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.operation }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client ID</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.client_id }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Client Name</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.client_name }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret ID</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.secret_id }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secret Name</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.secret_name }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Message</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.message }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Origin</dt>
|
||||
<dd class="text-xs font-semibold">{{ entry.origin }}</dd>
|
||||
</div>
|
||||
{% if entry.data %}
|
||||
{% for key, value in entry.data.items() %}
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">{{ key | capitalize }}</dt>
|
||||
<dd class="text-xs font-semibold">{{ value }}</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.subsystem }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.message }}
|
||||
</td>
|
||||
|
||||
<td class="p-4 text-sm font-normal text-gray-900 whitespace-nowrap dark:text-white">
|
||||
{{ entry.origin }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</sl-details>
|
||||
</div>
|
||||
@ -1,101 +0,0 @@
|
||||
function createCrumb(name, url = null) {
|
||||
// Create a breadcrumb
|
||||
const crumb = document.createElement("sl-breadcrumb-item");
|
||||
crumb.classList.add("page-breadcrumb");
|
||||
|
||||
if (url) {
|
||||
var crumbChild = document.createElement("a");
|
||||
crumbChild.setAttribute("href", url);
|
||||
const crumbChildText = document.createTextNode(name);
|
||||
crumbChild.appendChild(crumbChildText);
|
||||
} else {
|
||||
var crumbChild = document.createTextNode(name);
|
||||
}
|
||||
|
||||
crumb.appendChild(crumbChild);
|
||||
|
||||
return crumb;
|
||||
}
|
||||
|
||||
function setGroupBreadcrumbs(name, path, secret = null) {
|
||||
// Set breadcrumbs for a whole group.
|
||||
const breadcrumbs = document.getElementById("breadcrumbs");
|
||||
// First, remove all existing page breadcrumbs
|
||||
console.log(`setGroupBreadcrumbs: ${name} ${path}`);
|
||||
let pageCrumbs = document.getElementsByClassName("page-breadcrumb");
|
||||
for (let i = 0; i < pageCrumbs.length; i++) {
|
||||
breadcrumbs.removeChild(pageCrumbs[i]);
|
||||
}
|
||||
// Re-create the breadcrumbs
|
||||
const newcrumbs = [
|
||||
["Secrets", "/secrets/"],
|
||||
["Groups", "/secrets/groups/"],
|
||||
];
|
||||
if (path) {
|
||||
const pathnodes = path.split("/");
|
||||
for (let i = 0; i < pathnodes.length; i++) {
|
||||
let pathnode = pathnodes[i];
|
||||
let nextnode = i + 1;
|
||||
let groupPathNodes = pathnodes.slice(0, nextnode);
|
||||
let groupPath = groupPathNodes.join("/");
|
||||
newcrumbs.push([pathnode, `/secrets/groups/${groupPath}`]);
|
||||
}
|
||||
} else {
|
||||
newcrumbs.push(["Ungrouped", "/secrets/groups/"]);
|
||||
}
|
||||
|
||||
if (secret) {
|
||||
newcrumbs.push([secret, `/secrets/secret/${secret}`]);
|
||||
}
|
||||
|
||||
for (let i = 0; i < newcrumbs.length; i++) {
|
||||
let crumbParam = newcrumbs[i];
|
||||
let newcrumb = createCrumb(crumbParam[0], crumbParam[1]);
|
||||
breadcrumbs.appendChild(newcrumb);
|
||||
}
|
||||
}
|
||||
|
||||
function addTreeListener() {
|
||||
const tree = document.querySelector("sl-tree");
|
||||
|
||||
if (!tree) return;
|
||||
|
||||
tree.addEventListener("sl-selection-change", (event) => {
|
||||
const selectedEl = event.detail.selection[0];
|
||||
|
||||
if (!selectedEl) return;
|
||||
|
||||
const type = selectedEl.dataset.type;
|
||||
const name = selectedEl.dataset.name;
|
||||
const groupPath = selectedEl.dataset.groupPath;
|
||||
console.log(`Event on ${type} ${name} path: ${groupPath}`);
|
||||
|
||||
if (!type || !name) return;
|
||||
|
||||
let url = "";
|
||||
if (type === "entry") {
|
||||
url = `/secrets/secret/${encodeURIComponent(name)}`;
|
||||
} else if (type === "group") {
|
||||
//url = `/secrets/partial/group/${encodeURIComponent(name)}`;
|
||||
url = `/secrets/group/${encodeURIComponent(groupPath)}`;
|
||||
} else if (type == "root") {
|
||||
url = `/secrets/group/`;
|
||||
}
|
||||
|
||||
if (url) {
|
||||
htmx.ajax("GET", url, {
|
||||
target: "#secretdetails",
|
||||
swap: "OuterHTML",
|
||||
indicator: ".secret-spinner",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
addTreeListener();
|
||||
});
|
||||
|
||||
document.addEventListener("htmx:afterSwap", () => {
|
||||
addTreeListener();
|
||||
});
|
||||
@ -1,25 +0,0 @@
|
||||
<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>
|
||||
@ -1,95 +0,0 @@
|
||||
<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>
|
||||
@ -1,128 +0,0 @@
|
||||
<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>
|
||||
@ -1,6 +0,0 @@
|
||||
{% extends "/shared/_base.html" %} {% block content %}
|
||||
|
||||
<h1>Hooray!</h1>
|
||||
<p>It worked!</p>
|
||||
<p>Welcome, {{ user.username }}</p>
|
||||
{% endblock %}
|
||||
@ -1 +0,0 @@
|
||||
"""Frontend views."""
|
||||
@ -1,91 +0,0 @@
|
||||
"""Audit view factory."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
import logging
|
||||
from typing import Annotated, cast
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
|
||||
from sshecret.backend import AuditFilter, Operation
|
||||
|
||||
from sshecret_admin.auth import LocalUserInfo
|
||||
from sshecret_admin.services import AdminBackend
|
||||
|
||||
from .common import PagingInfo
|
||||
from ..dependencies import FrontendDependencies
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
"""Create clients router."""
|
||||
|
||||
app = APIRouter()
|
||||
templates = dependencies.templates
|
||||
|
||||
async def resolve_audit_entries(
|
||||
request: Request,
|
||||
current_user: LocalUserInfo,
|
||||
admin: AdminBackend,
|
||||
page: int,
|
||||
filters: AuditFilter,
|
||||
) -> Response:
|
||||
"""Resolve audit entries."""
|
||||
LOG.info("Page: %r", page)
|
||||
per_page = 20
|
||||
offset = 0
|
||||
if page > 1:
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
filter_args = cast(dict[str, str], filters.model_dump(exclude_none=True))
|
||||
audit_log = await admin.get_audit_log_detailed(offset, per_page, **filter_args)
|
||||
page_info = PagingInfo(
|
||||
page=page, limit=per_page, total=audit_log.total, offset=offset
|
||||
)
|
||||
operations = list(Operation)
|
||||
breadcrumbs = [("Audit", "/audit/")]
|
||||
if request.headers.get("HX-Request"):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"audit/inner.html.j2",
|
||||
{
|
||||
"entries": audit_log.results,
|
||||
"page_info": page_info,
|
||||
"operations": operations,
|
||||
},
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"audit/index.html.j2",
|
||||
{
|
||||
"page_title": "Audit Log",
|
||||
"breadcrumbs": breadcrumbs,
|
||||
"entries": audit_log.results,
|
||||
"user": current_user,
|
||||
"page_info": page_info,
|
||||
"operations": operations,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/audit/")
|
||||
async def get_audit_entries(
|
||||
request: Request,
|
||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
filters: Annotated[AuditFilter, Depends()],
|
||||
) -> Response:
|
||||
"""Get audit entries."""
|
||||
return await resolve_audit_entries(request, current_user, admin, 1, filters)
|
||||
|
||||
@app.get("/audit/page/{page}")
|
||||
async def get_audit_entries_page(
|
||||
request: Request,
|
||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
filters: Annotated[AuditFilter, Depends()],
|
||||
page: int,
|
||||
) -> Response:
|
||||
"""Get audit entries."""
|
||||
LOG.info("Get audit entries page: %r", page)
|
||||
return await resolve_audit_entries(request, current_user, admin, page, filters)
|
||||
|
||||
return app
|
||||
@ -1,214 +0,0 @@
|
||||
"""Authentication related views factory."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
import logging
|
||||
from pydantic import BaseModel
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sshecret_admin.services import AdminBackend
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from sshecret_admin.auth import (
|
||||
IdentityClaims,
|
||||
authenticate_user_async,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
)
|
||||
|
||||
from sshecret.backend.models import Operation
|
||||
|
||||
from ..dependencies import FrontendDependencies
|
||||
from ..exceptions import RedirectException
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginError(BaseModel):
|
||||
"""Login error."""
|
||||
|
||||
title: str
|
||||
message: str
|
||||
|
||||
|
||||
class OidcLogin(BaseModel):
|
||||
"""Small container to hold OIDC info for the login box."""
|
||||
|
||||
enabled: bool = False
|
||||
provider_name: str | None = None
|
||||
|
||||
|
||||
async def audit_login_failure(
|
||||
admin: AdminBackend, username: str, request: Request
|
||||
) -> None:
|
||||
"""Write login failure to audit log."""
|
||||
origin: str | None = None
|
||||
if request.client:
|
||||
origin = request.client.host
|
||||
await admin.write_audit_message(
|
||||
operation=Operation.DENY,
|
||||
message="Login failed",
|
||||
origin=origin or "UNKNOWN",
|
||||
username=username,
|
||||
)
|
||||
|
||||
|
||||
def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
"""Create auth router."""
|
||||
|
||||
app = APIRouter()
|
||||
templates = dependencies.templates
|
||||
|
||||
@app.get("/login")
|
||||
async def get_login(
|
||||
request: Request,
|
||||
login_status: Annotated[bool, Depends(dependencies.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:
|
||||
LOG.info("Got an error here: %s %s", error_title, error_message)
|
||||
login_error = LoginError(title=error_title, message=error_message)
|
||||
else:
|
||||
LOG.info("Got no errors")
|
||||
|
||||
oidc_login = OidcLogin()
|
||||
if dependencies.settings.oidc:
|
||||
oidc_login.enabled = True
|
||||
oidc_login.provider_name = dependencies.settings.oidc.name
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{
|
||||
"page_title": "Login",
|
||||
"page_description": "Login page.",
|
||||
"login_error": login_error,
|
||||
"oidc": oidc_login,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/login")
|
||||
async def login_user(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
next: Annotated[str, Query()] = "/dashboard",
|
||||
error_title: str | None = None,
|
||||
error_message: str | None = None,
|
||||
):
|
||||
"""Log in user."""
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
user = await authenticate_user_async(
|
||||
session, form_data.username, form_data.password
|
||||
)
|
||||
login_failed = RedirectException(
|
||||
to=URL("/login").include_query_params(
|
||||
error_title="Login Error", error_message="Invalid username or password"
|
||||
)
|
||||
)
|
||||
if not user:
|
||||
await audit_login_failure(admin, form_data.username, request)
|
||||
raise login_failed
|
||||
token_data: dict[str, str] = {"sub": user.username}
|
||||
access_token = create_access_token(dependencies.settings, data=token_data)
|
||||
refresh_token = create_refresh_token(dependencies.settings, data=token_data)
|
||||
response = RedirectResponse(url=next, status_code=status.HTTP_302_FOUND)
|
||||
response.set_cookie(
|
||||
"access_token",
|
||||
value=access_token,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="strict",
|
||||
)
|
||||
response.set_cookie(
|
||||
"refresh_token",
|
||||
value=refresh_token,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="strict",
|
||||
)
|
||||
origin = "UNKNOWN"
|
||||
if request.client:
|
||||
origin = request.client.host
|
||||
|
||||
await admin.write_audit_message(
|
||||
operation=Operation.LOGIN,
|
||||
message="Logged in to admin frontend",
|
||||
origin=origin,
|
||||
username=form_data.username,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@app.get("/refresh")
|
||||
async def get_refresh_token(
|
||||
response: Response,
|
||||
refresh_claims: Annotated[
|
||||
IdentityClaims, Depends(dependencies.get_refresh_claims)
|
||||
],
|
||||
next: Annotated[str, Query()],
|
||||
):
|
||||
"""Refresh tokens.
|
||||
|
||||
We might as well refresh the long-lived one here.
|
||||
"""
|
||||
token_data: dict[str, str] = {"sub": refresh_claims.sub}
|
||||
access_token = create_access_token(
|
||||
dependencies.settings, data=token_data, provider=refresh_claims.provider
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
dependencies.settings, data=token_data, provider=refresh_claims.provider
|
||||
)
|
||||
response = RedirectResponse(url=next, status_code=status.HTTP_302_FOUND)
|
||||
response.set_cookie(
|
||||
"access_token",
|
||||
value=access_token,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="strict",
|
||||
)
|
||||
response.set_cookie(
|
||||
"refresh_token",
|
||||
value=refresh_token,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="strict",
|
||||
)
|
||||
return response
|
||||
|
||||
@app.get("/logout")
|
||||
async def logout(
|
||||
response: Response,
|
||||
):
|
||||
"""Log out user."""
|
||||
response = RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
|
||||
response.delete_cookie(
|
||||
"refresh_token", httponly=True, secure=False, samesite="strict"
|
||||
)
|
||||
response.delete_cookie(
|
||||
"access_token", httponly=True, secure=False, samesite="strict"
|
||||
)
|
||||
return response
|
||||
|
||||
return app
|
||||
@ -1,350 +0,0 @@
|
||||
"""clients view factory."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
import ipaddress
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, Response
|
||||
from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork
|
||||
from sshecret_admin.frontend.views.common import PagingInfo
|
||||
|
||||
from sshecret.backend import ClientFilter
|
||||
from sshecret.backend.models import Client, ClientQueryResult, FilterType
|
||||
from sshecret.crypto import validate_public_key
|
||||
from sshecret_admin.auth import LocalUserInfo
|
||||
from sshecret_admin.services import AdminBackend
|
||||
|
||||
from ..dependencies import FrontendDependencies
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
CLIENTS_PER_PAGE = 20
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class LocatedClient(BaseModel):
|
||||
"""A located client."""
|
||||
|
||||
client: Client
|
||||
results: ClientQueryResult
|
||||
pages: PagingInfo
|
||||
|
||||
|
||||
async def locate_client(admin: AdminBackend, client_id: str) -> LocatedClient | None:
|
||||
"""Locate a client in a paginated dataset."""
|
||||
offset = 0
|
||||
page = 1
|
||||
total_clients = await admin.get_client_count()
|
||||
while offset < total_clients:
|
||||
filter = ClientFilter(limit=CLIENTS_PER_PAGE, offset=offset)
|
||||
results = await admin.query_clients(filter)
|
||||
matches = [client for client in results.clients if str(client.id) == client_id]
|
||||
if matches:
|
||||
client = matches[0]
|
||||
pages = PagingInfo(
|
||||
page=page,
|
||||
limit=CLIENTS_PER_PAGE,
|
||||
total=results.total_results,
|
||||
offset=offset,
|
||||
)
|
||||
return LocatedClient(client=client, results=results, pages=pages)
|
||||
offset += CLIENTS_PER_PAGE
|
||||
page += 1
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
"""Create clients router."""
|
||||
|
||||
app = APIRouter(dependencies=[Depends(dependencies.require_login)])
|
||||
|
||||
templates = dependencies.templates
|
||||
|
||||
@app.get("/clients/")
|
||||
async def get_client_tree(
|
||||
request: Request,
|
||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> Response:
|
||||
"""Get client tree view."""
|
||||
page = 1
|
||||
per_page = CLIENTS_PER_PAGE
|
||||
offset = 0
|
||||
|
||||
client_filter = ClientFilter(offset=offset, limit=per_page)
|
||||
results = await admin.query_clients(client_filter)
|
||||
paginate = PagingInfo(
|
||||
page=page, limit=per_page, total=results.total_results, offset=offset
|
||||
)
|
||||
|
||||
breadcrumbs = [("clients", "/clients/")]
|
||||
|
||||
LOG.info("Results %r", results)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"clients/index.html.j2",
|
||||
{
|
||||
"breadcrumbs": breadcrumbs,
|
||||
"page_title": "Clients",
|
||||
"offset": offset,
|
||||
"pages": paginate,
|
||||
"clients": results.clients,
|
||||
"user": current_user,
|
||||
"results": results,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/clients/page/{page}")
|
||||
async def get_client_page(
|
||||
request: Request,
|
||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
page: int,
|
||||
) -> Response:
|
||||
"""Get client tree view."""
|
||||
per_page = CLIENTS_PER_PAGE
|
||||
offset = 0
|
||||
if page > 1:
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
client_filter = ClientFilter(offset=offset, limit=per_page)
|
||||
results = await admin.query_clients(client_filter)
|
||||
paginate = PagingInfo(
|
||||
page=page,
|
||||
limit=per_page,
|
||||
offset=offset,
|
||||
total=results.total_results,
|
||||
)
|
||||
LOG.info("Results %r", results)
|
||||
template = "clients/index.html.j2"
|
||||
if request.headers.get("HX-Request"):
|
||||
# This is a HTMX request.
|
||||
template = "clients/partials/tree.html.j2"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
template,
|
||||
{
|
||||
"page_title": "Clients",
|
||||
"offset": offset,
|
||||
"last_num": offset + per_page,
|
||||
"pages": paginate,
|
||||
"clients": results.clients,
|
||||
"user": current_user,
|
||||
"results": results,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/clients/client/{id}")
|
||||
async def get_client(
|
||||
request: Request,
|
||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
id: str,
|
||||
) -> Response:
|
||||
"""Fetch a client."""
|
||||
|
||||
results = await locate_client(admin, id)
|
||||
|
||||
if not results:
|
||||
raise HTTPException(status_code=404, detail="Client not found.")
|
||||
events = await admin.get_audit_log_detailed(
|
||||
limit=10, client_name=results.client.name
|
||||
)
|
||||
template = "clients/client.html.j2"
|
||||
|
||||
breadcrumbs = [
|
||||
("clients", "/clients/"),
|
||||
(results.client.name, request.url.path),
|
||||
]
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
if request.headers.get("HX-Request"):
|
||||
headers["HX-Push-Url"] = request.url.path
|
||||
template = "clients/partials/client_details.html.j2"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
template,
|
||||
{
|
||||
"page_title": f"Client {results.client.name}",
|
||||
"breadcrumbs": breadcrumbs,
|
||||
"pages": results.pages,
|
||||
"clients": results.results.clients,
|
||||
"client": results.client,
|
||||
"user": current_user,
|
||||
"results": results.results,
|
||||
"events": events,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@app.put("/clients/{id}")
|
||||
async def update_client(
|
||||
request: Request,
|
||||
id: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
client: Annotated[ClientUpdate, Form()],
|
||||
):
|
||||
"""Update a client."""
|
||||
original_client = await admin.get_client(("id", 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)
|
||||
|
||||
final_client = await admin.update_client(updated_client)
|
||||
|
||||
events = await admin.get_audit_log_detailed(limit=10, client_name=client.name)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"clients/partials/client_details.html.j2",
|
||||
{
|
||||
"client": final_client,
|
||||
"events": events,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/clients/")
|
||||
async def create_client(
|
||||
request: Request,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
client: Annotated[ClientCreate, Form()],
|
||||
) -> Response:
|
||||
"""Create client."""
|
||||
sources: list[str] | None = None
|
||||
if client.sources:
|
||||
sources = [source.strip() for source in client.sources.split(",")]
|
||||
|
||||
headers = {"Hx-Refresh": "true"}
|
||||
return Response(
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@app.delete("/clients/{id}")
|
||||
async def delete_client(
|
||||
id: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
) -> Response:
|
||||
"""Delete a client."""
|
||||
await admin.delete_client(("id", id))
|
||||
headers = {"Hx-Refresh": "true"}
|
||||
return Response(headers=headers)
|
||||
|
||||
@app.post("/clients/validate/source")
|
||||
async def validate_client_source(
|
||||
request: Request,
|
||||
sources: Annotated[str, Form()],
|
||||
) -> Response:
|
||||
"""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,
|
||||
public_key: Annotated[str, Form()],
|
||||
) -> Response:
|
||||
"""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."},
|
||||
)
|
||||
|
||||
@app.post("/clients/query")
|
||||
async def query_clients(
|
||||
request: Request,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
query: Annotated[str, Form()],
|
||||
) -> Response:
|
||||
"""Query for a client."""
|
||||
query_filter = ClientFilter(limit=CLIENTS_PER_PAGE)
|
||||
if query:
|
||||
name = f"%{query}%"
|
||||
query_filter = ClientFilter(
|
||||
name=name, filter_name=FilterType.LIKE, limit=CLIENTS_PER_PAGE
|
||||
)
|
||||
results = await admin.query_clients(query_filter)
|
||||
pages: PagingInfo | None = None
|
||||
if not query:
|
||||
pages = PagingInfo(
|
||||
page=1, limit=CLIENTS_PER_PAGE, offset=0, total=results.total_results
|
||||
)
|
||||
|
||||
more_results: int | None = None
|
||||
if query and results.total_results > CLIENTS_PER_PAGE:
|
||||
more_results = results.total_results - CLIENTS_PER_PAGE
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"clients/partials/tree_items.html.j2",
|
||||
{
|
||||
"clients": results.clients,
|
||||
"pages": pages,
|
||||
"results": results,
|
||||
"more_results": more_results,
|
||||
},
|
||||
)
|
||||
|
||||
return app
|
||||
@ -1,41 +0,0 @@
|
||||
"""Common utilities."""
|
||||
|
||||
import math
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@property
|
||||
def pages(self) -> list[int]:
|
||||
"""Return all page numbers."""
|
||||
return [page for page in range(1, self.total_pages + 1)]
|
||||
|
||||
@property
|
||||
def is_last(self) -> bool:
|
||||
"""Is this the last page?"""
|
||||
return self.page == self.total_pages
|
||||
|
||||
@property
|
||||
def is_first(self) -> bool:
|
||||
"""Is this the first page?"""
|
||||
return self.page == 1
|
||||
@ -1,190 +0,0 @@
|
||||
"""Front page view factory."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sshecret_admin.auth import LocalUserInfo, authenticate_user_async
|
||||
from sshecret_admin.auth.authentication import hash_password
|
||||
from sshecret_admin.frontend.exceptions import RedirectException
|
||||
from sshecret_admin.services import AdminBackend
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from sshecret.backend.models import Operation
|
||||
|
||||
from ..dependencies import FrontendDependencies
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
START_PAGE = "/dashboard"
|
||||
LOGIN_PAGE = "/login"
|
||||
|
||||
|
||||
class StatsView(BaseModel):
|
||||
"""Stats for the frontend."""
|
||||
|
||||
clients: int = 0
|
||||
secrets: int = 0
|
||||
audit_events: int = 0
|
||||
|
||||
|
||||
class PasswordChangeForm(BaseModel):
|
||||
"""Password change form data."""
|
||||
|
||||
current_password: str
|
||||
password: str
|
||||
confirm_password: str
|
||||
|
||||
|
||||
async def get_stats(admin: AdminBackend) -> StatsView:
|
||||
"""Get stats for the frontpage."""
|
||||
clients = await admin.get_clients()
|
||||
secrets = await admin.get_secrets()
|
||||
audit = await admin.get_audit_log_count()
|
||||
return StatsView(clients=len(clients), secrets=len(secrets), audit_events=audit)
|
||||
|
||||
|
||||
def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
"""Create auth router."""
|
||||
|
||||
app = APIRouter()
|
||||
templates = dependencies.templates
|
||||
|
||||
@app.get("/")
|
||||
def get_index(logged_in: Annotated[bool, Depends(dependencies.get_login_status)]):
|
||||
"""Get the index."""
|
||||
next = LOGIN_PAGE
|
||||
if logged_in:
|
||||
next = START_PAGE
|
||||
|
||||
return RedirectResponse(url=next)
|
||||
|
||||
@app.get("/dashboard")
|
||||
async def get_dashboard(
|
||||
request: Request,
|
||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
):
|
||||
"""Dashboard for mocking up the dashboard."""
|
||||
stats = await get_stats(admin)
|
||||
last_login_events = await admin.get_audit_log_detailed(
|
||||
limit=5, operation="login"
|
||||
)
|
||||
last_audit_events = await admin.get_audit_log_detailed(limit=10)
|
||||
|
||||
LOG.info("CurrentUser: %r", current_user)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard.html",
|
||||
{
|
||||
"page_title": "Dashboard",
|
||||
"user": current_user,
|
||||
"stats": stats,
|
||||
"last_login_events": last_login_events,
|
||||
"last_audit_events": last_audit_events,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/password")
|
||||
async def get_change_password(
|
||||
request: Request,
|
||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||
):
|
||||
"""Render Change password site."""
|
||||
if not current_user.local:
|
||||
LOG.debug("User tried to change password, but is not a local user.")
|
||||
return RedirectException(to=URL("/"))
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"change_password/index.html.j2",
|
||||
{
|
||||
"page_title": "Change Password",
|
||||
"user": current_user,
|
||||
"errors": [],
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/password")
|
||||
async def change_password(
|
||||
request: Request,
|
||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
|
||||
passwd_form: Annotated[PasswordChangeForm, Form()],
|
||||
):
|
||||
"""Change password."""
|
||||
errors: list[str] = []
|
||||
user = await authenticate_user_async(
|
||||
session, current_user.display_name, passwd_form.current_password
|
||||
)
|
||||
new_password_matches = passwd_form.password == passwd_form.confirm_password
|
||||
if not user:
|
||||
errors.append("Invalid current password entered")
|
||||
if not new_password_matches:
|
||||
errors.append("Passwords do not match")
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"change_password/index.html.j2",
|
||||
{
|
||||
"page_title": "Change Password",
|
||||
"user": current_user,
|
||||
"errors": errors,
|
||||
},
|
||||
)
|
||||
|
||||
assert user is not None
|
||||
new_password_hash = hash_password(passwd_form.password)
|
||||
user.hashed_password = new_password_hash
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
origin = "UNKNOWN"
|
||||
if request.client:
|
||||
origin = request.client.host
|
||||
await admin.write_audit_message(
|
||||
Operation.UPDATE,
|
||||
"User changed their password",
|
||||
origin,
|
||||
username=user.username,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"change_password/success.html.j2",
|
||||
{
|
||||
"page_title": "Change Password success",
|
||||
"user": current_user,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/password/validate-confirm")
|
||||
async def validate_password_match(
|
||||
request: Request,
|
||||
password: Annotated[str, Form()],
|
||||
confirm_password: Annotated[str, Form()],
|
||||
):
|
||||
"""Validate password matches."""
|
||||
valid = "/change_password/valid_password.html.j2"
|
||||
invalid = "/change_password/invalid_password.html.j2"
|
||||
template = valid
|
||||
if password != confirm_password:
|
||||
template = invalid
|
||||
|
||||
LOG.info("Password matches: %r", (password == confirm_password))
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
template,
|
||||
{
|
||||
"password": password,
|
||||
"confirm_password": confirm_password,
|
||||
},
|
||||
)
|
||||
|
||||
return app
|
||||
@ -1,142 +0,0 @@
|
||||
"""Optional OIDC auth module."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sshecret_admin.auth import create_access_token, create_refresh_token
|
||||
from sshecret_admin.auth.authentication import generate_user_info, handle_oidc_claim
|
||||
from sshecret_admin.auth.exceptions import AuthenticationFailedError
|
||||
from sshecret_admin.auth.oidc import AdminOidc
|
||||
from sshecret_admin.frontend.exceptions import RedirectException
|
||||
from sshecret_admin.services import AdminBackend
|
||||
from starlette.datastructures import URL
|
||||
|
||||
from sshecret.backend.models import Operation
|
||||
|
||||
from ..dependencies import FrontendDependencies
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def audit_login_failure(
|
||||
admin: AdminBackend,
|
||||
error_message: str,
|
||||
request: Request,
|
||||
) -> None:
|
||||
"""Write login failure to audit log."""
|
||||
origin: str | None = None
|
||||
if request.client:
|
||||
origin = request.client.host
|
||||
await admin.write_audit_message(
|
||||
operation=Operation.DENY,
|
||||
message="Login failed",
|
||||
origin=origin or "UNKNOWN",
|
||||
provider_error_message=error_message,
|
||||
)
|
||||
|
||||
|
||||
def create_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
"""Create auth router."""
|
||||
|
||||
app = APIRouter()
|
||||
|
||||
def get_oidc_client() -> AdminOidc:
|
||||
"""Get OIDC client dependency."""
|
||||
if not dependencies.settings.oidc:
|
||||
raise RuntimeError("OIDC authentication not configured.")
|
||||
oidc = AdminOidc(dependencies.settings.oidc)
|
||||
return oidc
|
||||
|
||||
@app.get("/oidc/login")
|
||||
async def oidc_login(
|
||||
request: Request, oidc: Annotated[AdminOidc, Depends(get_oidc_client)]
|
||||
) -> RedirectResponse:
|
||||
"""Redirect to oidc login."""
|
||||
redirect_url = request.url_for("oidc_auth")
|
||||
return await oidc.start_auth(request, redirect_url)
|
||||
|
||||
@app.get("/oidc/auth")
|
||||
async def oidc_auth(
|
||||
request: Request,
|
||||
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
oidc: Annotated[AdminOidc, Depends(get_oidc_client)],
|
||||
):
|
||||
"""Handle OIDC auth callback."""
|
||||
try:
|
||||
claims = await oidc.handle_auth_callback(request)
|
||||
except AuthenticationFailedError as error:
|
||||
raise RedirectException(
|
||||
to=URL("/login").include_query_params(
|
||||
error_title="Login error from external provider",
|
||||
error_message=str(error),
|
||||
)
|
||||
)
|
||||
except ValidationError as error:
|
||||
LOG.error("Validation error: %s", error, exc_info=True)
|
||||
raise RedirectException(
|
||||
to=URL("/login").include_query_params(
|
||||
error_title="Error parsing claim",
|
||||
error_message="One or more required parameters were not included in the claim.",
|
||||
)
|
||||
)
|
||||
|
||||
# We now have a IdentityClaims object.
|
||||
# We need to check if this matches an existing user, or we need to create a new one.
|
||||
|
||||
user = await handle_oidc_claim(session, claims)
|
||||
user.last_login = datetime.now()
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
# Set cookies
|
||||
token_data: dict[str, str] = {"sub": claims.sub}
|
||||
access_token = create_access_token(
|
||||
dependencies.settings, data=token_data, provider=claims.provider
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
dependencies.settings, data=token_data, provider=claims.provider
|
||||
)
|
||||
user_info = generate_user_info(user)
|
||||
response = HTMLResponse("""
|
||||
<html>
|
||||
<body>
|
||||
<p>Login successful. Redirecting...</p>
|
||||
<script>
|
||||
setTimeout(() => { window.location.href = "/dashboard"; }, 500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
response.set_cookie(
|
||||
"access_token",
|
||||
value=access_token,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="strict",
|
||||
)
|
||||
response.set_cookie(
|
||||
"refresh_token",
|
||||
value=refresh_token,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="strict",
|
||||
)
|
||||
origin = "UNKNOWN"
|
||||
if request.client:
|
||||
origin = request.client.host
|
||||
await admin.write_audit_message(
|
||||
operation=Operation.LOGIN,
|
||||
message="Logged in to admin frontend",
|
||||
origin=origin,
|
||||
username=user_info.display_name,
|
||||
oidc=claims.provider,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
return app
|
||||
@ -1,566 +0,0 @@
|
||||
"""Secrets views."""
|
||||
|
||||
# pyright: reportUnusedFunction=false
|
||||
import os
|
||||
import logging
|
||||
import secrets as pysecrets
|
||||
from typing import Annotated, Any
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from pydantic import BaseModel, BeforeValidator, Field
|
||||
|
||||
from sshecret_admin.auth import LocalUserInfo
|
||||
from sshecret_admin.services import AdminBackend
|
||||
from sshecret_admin.services.models import SecretGroupCreate
|
||||
|
||||
from sshecret.backend.models import Operation
|
||||
|
||||
from ..dependencies import FrontendDependencies
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def split_clients(clients: Any) -> Any: # pyright: ignore[reportAny]
|
||||
"""Split clients."""
|
||||
if isinstance(clients, list):
|
||||
return clients # pyright: ignore[reportUnknownVariableType]
|
||||
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: # pyright: ignore[reportAny]
|
||||
"""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_router(dependencies: FrontendDependencies) -> APIRouter:
|
||||
"""Create secrets router."""
|
||||
|
||||
app = APIRouter(dependencies=[Depends(dependencies.require_login)])
|
||||
templates = dependencies.templates
|
||||
|
||||
@app.get("/secrets/")
|
||||
async def get_secrets_tree(
|
||||
request: Request,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||
):
|
||||
breadcrumbs = [("secrets", "/secrets/")]
|
||||
groups = await admin.get_secret_groups()
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/index.html.j2",
|
||||
{
|
||||
"page_title": "Secrets",
|
||||
"groups": groups,
|
||||
"breadcrumbs": breadcrumbs,
|
||||
"user": current_user,
|
||||
"selected_group": None,
|
||||
"group_path_nodes": ["/"],
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/secrets/group/")
|
||||
async def show_root_group(
|
||||
request: Request,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||
):
|
||||
"""Show the root path."""
|
||||
clients = await admin.get_clients()
|
||||
|
||||
breadcrumbs = [
|
||||
("secrets", "/secrets/"),
|
||||
("groups", "/secrets/groups/"),
|
||||
("Ungrouped", "/secrets/groups/"),
|
||||
]
|
||||
context: dict[str, Any] = {
|
||||
"clients": clients,
|
||||
"breadcrumbs": breadcrumbs,
|
||||
"root_group_page": True,
|
||||
}
|
||||
headers: dict[str, str] = {}
|
||||
if request.headers.get("HX-Request"):
|
||||
# This is a HTMX request.
|
||||
template_name = "secrets/partials/edit_root.html.j2"
|
||||
headers["HX-Push-Url"] = request.url.path
|
||||
else:
|
||||
groups = await admin.get_secret_groups()
|
||||
template_name = "secrets/index.html.j2"
|
||||
context["page_title"] = "Secrets"
|
||||
context["user"] = current_user
|
||||
context["groups"] = groups
|
||||
context["group_path_nodes"] = ["/"]
|
||||
context["selected_group"] = "/"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request, template_name, context, headers=headers
|
||||
)
|
||||
|
||||
@app.get("/secrets/group/{group_path:path}")
|
||||
async def show_group(
|
||||
request: Request,
|
||||
group_path: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||
):
|
||||
"""Show a group."""
|
||||
group = await admin.get_secret_group_by_path(group_path)
|
||||
if not group:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
|
||||
)
|
||||
clients = await admin.get_clients()
|
||||
|
||||
breadcrumbs = [("secrets", "/secrets/"), ("groups", "/secrets/groups/")]
|
||||
path_nodes = group.path.split("/")
|
||||
for x in range(len(path_nodes)):
|
||||
next_node = x + 1
|
||||
group_path = "/".join(path_nodes[:next_node])
|
||||
crumb_path = os.path.join("/secrets", group_path)
|
||||
breadcrumbs.append((path_nodes[x], crumb_path))
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
context: dict[str, Any] = {
|
||||
"group_page": True,
|
||||
"group": group,
|
||||
"clients": clients,
|
||||
"breadcrumbs": breadcrumbs,
|
||||
}
|
||||
if request.headers.get("HX-Request"):
|
||||
# This is a HTMX request.
|
||||
template_name = "secrets/partials/group_detail.html.j2"
|
||||
headers["HX-Push-Url"] = request.url.path
|
||||
else:
|
||||
template_name = "secrets/index.html.j2"
|
||||
|
||||
groups = await admin.get_secret_groups()
|
||||
context["page_title"] = "Secrets"
|
||||
context["user"] = current_user
|
||||
context["groups"] = groups
|
||||
context["group_path_nodes"] = group.path.split("/")
|
||||
context["selected_group"] = group.path
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request, template_name, context, headers=headers
|
||||
)
|
||||
|
||||
@app.get("/secrets/secret/{name}")
|
||||
async def get_secret_tree_detail(
|
||||
request: Request,
|
||||
name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||
):
|
||||
"""Get secret detail."""
|
||||
secret = await admin.get_secret(name)
|
||||
groups = await admin.get_secret_groups()
|
||||
events = await admin.get_audit_log_detailed(limit=10, secret_name=name)
|
||||
|
||||
if not secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
|
||||
)
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"secret": secret,
|
||||
"groups": groups,
|
||||
"events": events,
|
||||
"secret_page": True,
|
||||
}
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
if request.headers.get("HX-Request"):
|
||||
# This is a HTMX request.
|
||||
template_name = "secrets/partials/tree_detail.html.j2"
|
||||
headers["HX-Push-Url"] = request.url.path
|
||||
else:
|
||||
group_path = ["/"]
|
||||
if secret.group:
|
||||
group = await admin.get_secret_group(secret.group)
|
||||
if group:
|
||||
group_path = group.path.split("/")
|
||||
|
||||
template_name = "secrets/index.html.j2"
|
||||
context["page_title"] = "Secrets"
|
||||
context["user"] = current_user
|
||||
context["groups"] = groups
|
||||
context["group_path_nodes"] = group_path
|
||||
context["selected_group"] = None
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request, template_name, context, headers=headers
|
||||
)
|
||||
|
||||
@app.delete("/secrets/group/{name}")
|
||||
async def delete_secret_group(
|
||||
request: Request,
|
||||
name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
):
|
||||
"""Delete a secret group."""
|
||||
group = await admin.get_secret_group(name)
|
||||
if not group:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
|
||||
)
|
||||
|
||||
await admin.delete_secret_group(name)
|
||||
|
||||
new_path = "/secrets/group/"
|
||||
if group.parent_group:
|
||||
new_path = os.path.join(new_path, group.parent_group.path)
|
||||
headers = {"Hx-Redirect": new_path}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/partials/redirect.html.j2",
|
||||
{"destination": new_path},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@app.post("/secrets/group/")
|
||||
async def create_group(
|
||||
request: Request,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
group: Annotated[SecretGroupCreate, Form()],
|
||||
):
|
||||
"""Create group."""
|
||||
|
||||
LOG.info("Creating secret group: %r", group)
|
||||
await admin.add_secret_group(
|
||||
group_name=group.name,
|
||||
description=group.description,
|
||||
parent_group=group.parent_group,
|
||||
)
|
||||
|
||||
headers = {"Hx-Refresh": "true"}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/partials/default_detail.html.j2",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@app.put("/secrets/set-group/{name}")
|
||||
async def set_secret_group(
|
||||
request: Request,
|
||||
name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
group_name: Annotated[str, Form()],
|
||||
):
|
||||
"""Move a secret to a group."""
|
||||
secret = await admin.get_secret(name)
|
||||
if not secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
|
||||
)
|
||||
|
||||
if group_name == "__ROOT":
|
||||
await admin.set_secret_group(name, None)
|
||||
|
||||
else:
|
||||
group = await admin.get_secret_group(group_name)
|
||||
if not group:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
|
||||
)
|
||||
await admin.set_secret_group(name, group_name)
|
||||
|
||||
groups = await admin.get_secret_groups()
|
||||
events = await admin.get_audit_log_detailed(limit=10, secret_name=secret.name)
|
||||
|
||||
secret = await admin.get_secret(name)
|
||||
|
||||
headers = {"Hx-Refresh": "true"}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/partials/tree_detail.html.j2",
|
||||
{
|
||||
"secret": secret,
|
||||
"groups": groups,
|
||||
"events": events,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@app.put("/secrets/partial/group/{name}/description")
|
||||
async def update_group_description(
|
||||
request: Request,
|
||||
name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
description: Annotated[str, Form()],
|
||||
):
|
||||
"""Update group description."""
|
||||
group = await admin.get_secret_group(name)
|
||||
|
||||
if not group:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Group not found"
|
||||
)
|
||||
await admin.set_group_description(group_name=name, description=description)
|
||||
clients = await admin.get_clients()
|
||||
headers = {"Hx-Refresh": "true"}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/partials/group_detail.html.j2",
|
||||
{
|
||||
"group": group,
|
||||
"clients": clients,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@app.put("/secrets/partial/secret/{name}/value")
|
||||
async def update_secret_value_inline(
|
||||
request: Request,
|
||||
name: str,
|
||||
secret_value: Annotated[str, Form()],
|
||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
):
|
||||
"""Update secret value."""
|
||||
secret = await admin.get_secret(name)
|
||||
if not secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
|
||||
)
|
||||
|
||||
origin = "UNKNOWN"
|
||||
if request.client:
|
||||
origin = request.client.host
|
||||
|
||||
await admin.write_audit_message(
|
||||
operation=Operation.UPDATE,
|
||||
message="Secret was updated via admin interface",
|
||||
secret_name=name,
|
||||
origin=origin,
|
||||
username=current_user.display_name,
|
||||
)
|
||||
|
||||
await admin.update_secret(name, secret_value)
|
||||
|
||||
secret = await admin.get_secret(name)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/partials/secret_value.html.j2",
|
||||
{
|
||||
"secret": secret,
|
||||
"updated": True,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/secrets/partial/{name}/viewsecret")
|
||||
async def view_secret_in_tree(
|
||||
request: Request,
|
||||
name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
current_user: Annotated[LocalUserInfo, Depends(dependencies.get_user_info)],
|
||||
):
|
||||
"""View secret inline partial."""
|
||||
secret = await admin.get_secret(name)
|
||||
|
||||
if not secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found"
|
||||
)
|
||||
origin = "UNKNOWN"
|
||||
if request.client:
|
||||
origin = request.client.host
|
||||
await admin.write_audit_message(
|
||||
operation=Operation.READ,
|
||||
message="Secret viewed",
|
||||
secret_name=name,
|
||||
origin=origin,
|
||||
username=current_user.display_name,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/partials/secret_value.html.j2",
|
||||
{
|
||||
"secret": secret,
|
||||
"updated": False,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/secrets/create/group/{name}")
|
||||
async def add_secret_in_group(
|
||||
request: Request,
|
||||
name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
secret: Annotated[CreateSecret, Form()],
|
||||
):
|
||||
"""Create secret in group."""
|
||||
if secret.value:
|
||||
value = secret.value
|
||||
else:
|
||||
value = pysecrets.token_urlsafe(32)
|
||||
|
||||
await admin.add_secret(secret.name, value, secret.clients, group=name)
|
||||
|
||||
new_path = f"/secrets/secret/{secret.name}"
|
||||
|
||||
headers = {"Hx-Redirect": new_path}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/partials/redirect.html.j2",
|
||||
{"destination": new_path},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@app.post("/secrets/create/root")
|
||||
async def add_secret_in_root(
|
||||
request: Request,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
secret: Annotated[CreateSecret, Form()],
|
||||
):
|
||||
"""Create secret in the root."""
|
||||
LOG.info("secret: %s", secret.model_dump_json(indent=2))
|
||||
if secret.value:
|
||||
value = secret.value
|
||||
else:
|
||||
value = pysecrets.token_urlsafe(32)
|
||||
|
||||
await admin.add_secret(secret.name, value, secret.clients, group=None)
|
||||
|
||||
new_path = f"/secrets/secret/{secret.name}"
|
||||
|
||||
headers = {"Hx-Redirect": new_path}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/partials/redirect.html.j2",
|
||||
{
|
||||
"destination": new_path,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
@app.delete("/secrets/{name}/clients/{client_name}")
|
||||
async def remove_client_secret_access(
|
||||
request: Request,
|
||||
name: str,
|
||||
client_name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
):
|
||||
"""Remove a client's access to a secret."""
|
||||
client = await admin.get_client(client_name)
|
||||
if not client:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Client not found."
|
||||
)
|
||||
|
||||
await admin.delete_client_secret(str(client.id), name)
|
||||
clients = await admin.get_clients()
|
||||
|
||||
secret = await admin.get_secret(name)
|
||||
if not secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found."
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/partials/client_list_inner.html.j2",
|
||||
{"clients": clients, "secret": secret},
|
||||
)
|
||||
|
||||
@app.get("/secrets/{name}/clients/")
|
||||
async def show_secret_client_add(
|
||||
request: Request,
|
||||
name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
):
|
||||
"""Show partial to add new client to a secret."""
|
||||
clients = await admin.get_clients()
|
||||
secret = await admin.get_secret(name)
|
||||
if not secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found."
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/partials/client_assign.html.j2",
|
||||
{
|
||||
"clients": clients,
|
||||
"secret": secret,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/secrets/{name}/clients/")
|
||||
async def add_secret_to_client(
|
||||
request: Request,
|
||||
name: str,
|
||||
client: Annotated[str, Form()],
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
):
|
||||
"""Add a secret to a client."""
|
||||
await admin.create_client_secret(("id", client), name)
|
||||
secret = await admin.get_secret(name)
|
||||
if not secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found."
|
||||
)
|
||||
clients = await admin.get_clients()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/partials/client_secret_details.html.j2",
|
||||
{
|
||||
"secret": secret,
|
||||
"clients": clients,
|
||||
},
|
||||
)
|
||||
|
||||
@app.delete("/secrets/{name}")
|
||||
async def delete_secret(
|
||||
request: Request,
|
||||
name: str,
|
||||
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||
):
|
||||
"""Delete a secret."""
|
||||
secret = await admin.get_secret(name)
|
||||
if not secret:
|
||||
raise HTTPException(status_code=404, detail="Secret not found")
|
||||
new_path = "/secrets/group/"
|
||||
if secret.group:
|
||||
secret_group = await admin.get_secret_group(secret.group)
|
||||
if secret_group:
|
||||
new_path = os.path.join("/secrets/group", secret_group.path)
|
||||
|
||||
await admin.delete_secret(name)
|
||||
headers = {"Hx-Redirect": new_path}
|
||||
# headers["HX-Push-Url"] = request.url.path
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"secrets/partials/redirect.html.j2",
|
||||
{"destination": new_path},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
return app
|
||||
@ -4,8 +4,9 @@ Since we have a frontend and a REST API, it makes sense to have a generic librar
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Literal, Unpack
|
||||
|
||||
from sshecret.backend import (
|
||||
AuditLog,
|
||||
@ -16,15 +17,32 @@ from sshecret.backend import (
|
||||
Operation,
|
||||
SubSystem,
|
||||
)
|
||||
from sshecret.backend.models import ClientQueryResult, DetailedSecrets
|
||||
from sshecret.backend.api import AuditAPI, KeySpec
|
||||
from sshecret.backend.exceptions import (
|
||||
BackendError,
|
||||
BackendValidationError,
|
||||
HttpErrorItem,
|
||||
)
|
||||
from sshecret.backend.identifiers import KeySpec
|
||||
from sshecret.backend.models import (
|
||||
ClientQueryResult,
|
||||
ClientReference,
|
||||
DetailedSecrets,
|
||||
SystemStats,
|
||||
)
|
||||
from sshecret.backend.api import AuditAPI
|
||||
from sshecret.crypto import encrypt_string, load_public_key
|
||||
|
||||
from .keepass import PasswordContext, load_password_manager
|
||||
from .secret_manager import (
|
||||
AsyncSecretContext,
|
||||
InvalidSecretNameError,
|
||||
SecretUpdateParams,
|
||||
password_manager_context,
|
||||
)
|
||||
from sshecret_admin.core.settings import AdminServerSettings
|
||||
from .models import (
|
||||
ClientSecretGroup,
|
||||
ClientSecretGroupList,
|
||||
GroupReference,
|
||||
SecretClientMapping,
|
||||
SecretListView,
|
||||
SecretGroup,
|
||||
@ -57,11 +75,15 @@ def add_clients_to_secret_group(
|
||||
parent: ClientSecretGroup | None = None,
|
||||
) -> ClientSecretGroup:
|
||||
"""Add client information to a secret group."""
|
||||
parent_ref = None
|
||||
if parent:
|
||||
parent_ref = parent.reference()
|
||||
client_secret_group = ClientSecretGroup(
|
||||
id=group.id,
|
||||
group_name=group.name,
|
||||
path=group.path,
|
||||
description=group.description,
|
||||
parent_group=parent,
|
||||
parent_group=parent_ref,
|
||||
)
|
||||
for entry in group.entries:
|
||||
secret_entries = SecretClientMapping(name=entry)
|
||||
@ -74,31 +96,38 @@ def add_clients_to_secret_group(
|
||||
subgroup, client_secret_mapping, client_secret_group
|
||||
)
|
||||
)
|
||||
# We'll save a bit of memory and complexity by just adding the name of the parent, if available.
|
||||
if not parent and group.parent_group:
|
||||
client_secret_group.parent_group = ClientSecretGroup(
|
||||
group_name=group.parent_group.name,
|
||||
path=group.parent_group.path,
|
||||
reference = GroupReference(
|
||||
group_name=group.parent_group.name, path=group.parent_group.path
|
||||
)
|
||||
client_secret_group.parent_group = reference
|
||||
return client_secret_group
|
||||
|
||||
|
||||
class AdminBackend:
|
||||
"""Admin backend API."""
|
||||
|
||||
def __init__(self, settings: AdminServerSettings, keepass_password: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
settings: AdminServerSettings,
|
||||
username: str | None = None,
|
||||
origin: str = "UNKNOWN",
|
||||
) -> 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
|
||||
self.username: str = username or "UKNOWN_USER"
|
||||
self.origin: str = origin
|
||||
|
||||
@contextmanager
|
||||
def password_manager(self) -> Iterator[PasswordContext]:
|
||||
"""Open the password manager."""
|
||||
with load_password_manager(self.settings, self.keepass_password) as kp:
|
||||
yield kp
|
||||
@asynccontextmanager
|
||||
async def secrets_manager(self) -> AsyncIterator[AsyncSecretContext]:
|
||||
"""Open the secrets manager."""
|
||||
async with password_manager_context(
|
||||
self.settings, self.username, self.origin
|
||||
) as manager:
|
||||
yield manager
|
||||
|
||||
async def _get_clients(self, filter: ClientFilter | None = None) -> list[Client]:
|
||||
"""Get clients from backend."""
|
||||
@ -113,6 +142,10 @@ class AdminBackend:
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def get_clients_terse(self) -> list[ClientReference]:
|
||||
"""Get a list of client ids and names."""
|
||||
return await self.backend.get_client_terse()
|
||||
|
||||
async def query_clients(
|
||||
self, filter: ClientFilter | None = None
|
||||
) -> ClientQueryResult:
|
||||
@ -187,14 +220,21 @@ class AdminBackend:
|
||||
return await self._create_client(name, public_key, description, sources)
|
||||
except ClientManagementError:
|
||||
raise
|
||||
except BackendValidationError as e:
|
||||
LOG.error("Validation error: %s", e, exc_info=True)
|
||||
raise e
|
||||
|
||||
except BackendError:
|
||||
raise
|
||||
except Exception as e:
|
||||
LOG.error("Exception: %s", e, exc_info=True)
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def _update_client_public_key(
|
||||
self,
|
||||
name: KeySpec,
|
||||
new_key: str,
|
||||
password_manager: PasswordContext,
|
||||
password_manager: AsyncSecretContext,
|
||||
) -> list[str]:
|
||||
"""Update client public key."""
|
||||
LOG.info(
|
||||
@ -207,7 +247,7 @@ class AdminBackend:
|
||||
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)
|
||||
secret_value = await password_manager.get_secret(secret)
|
||||
if not secret_value:
|
||||
LOG.warning(
|
||||
"Referenced secret %s does not exist! Skipping.", secret_value
|
||||
@ -224,7 +264,7 @@ class AdminBackend:
|
||||
async def update_client_public_key(self, name: KeySpec, new_key: str) -> list[str]:
|
||||
"""Update client public key."""
|
||||
try:
|
||||
with self.password_manager() as password_manager:
|
||||
async with self.secrets_manager() as password_manager:
|
||||
return await self._update_client_public_key(
|
||||
name, new_key, password_manager
|
||||
)
|
||||
@ -291,8 +331,8 @@ class AdminBackend:
|
||||
This fetches the secret to client mapping from backend, and adds secrets from the password manager.
|
||||
"""
|
||||
backend_secrets = await self.backend.get_secrets()
|
||||
with self.password_manager() as password_manager:
|
||||
admin_secrets = password_manager.get_available_secrets()
|
||||
async with self.secrets_manager() as password_manager:
|
||||
admin_secrets = await password_manager.get_available_secrets()
|
||||
|
||||
secrets: dict[str, SecretListView] = {}
|
||||
for secret in backend_secrets:
|
||||
@ -324,8 +364,8 @@ class AdminBackend:
|
||||
|
||||
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()
|
||||
async with self.secrets_manager() as password_manager:
|
||||
all_secrets = await password_manager.get_available_secrets()
|
||||
|
||||
secrets = await self.backend.get_detailed_secrets()
|
||||
backend_secret_names = [secret.name for secret in secrets]
|
||||
@ -351,38 +391,39 @@ class AdminBackend:
|
||||
parent_group: str | None = None,
|
||||
) -> None:
|
||||
"""Add secret group."""
|
||||
with self.password_manager() as password_manager:
|
||||
password_manager.add_group(group_name, description, parent_group)
|
||||
async with self.secrets_manager() as password_manager:
|
||||
await password_manager.add_group(group_name, description, parent_group)
|
||||
|
||||
async def set_secret_group(self, secret_name: str, group_name: str | None) -> None:
|
||||
"""Assign a group to a secret."""
|
||||
with self.password_manager() as password_manager:
|
||||
password_manager.set_secret_group(secret_name, group_name)
|
||||
async with self.secrets_manager() as password_manager:
|
||||
await password_manager.set_secret_group(secret_name, group_name)
|
||||
|
||||
async def move_secret_group(
|
||||
self, group_name: str, parent_group: str | None
|
||||
) -> None:
|
||||
async def move_secret_group(self, group_name: str, parent_group: str | None) -> str:
|
||||
"""Move a group.
|
||||
|
||||
If parent_group is None, it will be moved to the root.
|
||||
Returns the new path of the group.
|
||||
"""
|
||||
with self.password_manager() as password_manager:
|
||||
password_manager.move_group(group_name, parent_group)
|
||||
async with self.secrets_manager() as password_manager:
|
||||
new_path = await password_manager.move_group(group_name, parent_group)
|
||||
|
||||
return new_path
|
||||
|
||||
async def set_group_description(self, group_name: str, description: str) -> None:
|
||||
"""Set a group description."""
|
||||
with self.password_manager() as password_manager:
|
||||
password_manager.set_group_description(group_name, description)
|
||||
async with self.secrets_manager() as password_manager:
|
||||
await password_manager.set_group_description(group_name, description)
|
||||
|
||||
async def delete_secret_group(
|
||||
self, group_name: str, keep_entries: bool = True
|
||||
) -> None:
|
||||
"""Delete a group.
|
||||
async def delete_secret_group(self, group_path: str) -> None:
|
||||
"""Delete a group."""
|
||||
async with self.secrets_manager() as password_manager:
|
||||
await password_manager.delete_group(group_path)
|
||||
|
||||
If keep_entries is set to False, all entries in the group will be deleted.
|
||||
"""
|
||||
with self.password_manager() as password_manager:
|
||||
password_manager.delete_group(group_name, keep_entries)
|
||||
async def delete_secret_group_by_id(self, id: str) -> None:
|
||||
"""Delete a secret group by ID."""
|
||||
async with self.secrets_manager() as password_manager:
|
||||
await password_manager.delete_group_id(id)
|
||||
|
||||
async def get_secret_groups(
|
||||
self,
|
||||
@ -399,18 +440,18 @@ class AdminBackend:
|
||||
"""
|
||||
all_secrets = await self.backend.get_detailed_secrets()
|
||||
secrets_mapping = {secret.name: secret for secret in all_secrets}
|
||||
with self.password_manager() as password_manager:
|
||||
async with self.secrets_manager() as password_manager:
|
||||
if flat:
|
||||
all_groups = password_manager.get_secret_group_list(
|
||||
all_groups = await password_manager.get_secret_group_list(
|
||||
group_filter, regex=regex
|
||||
)
|
||||
else:
|
||||
all_groups = password_manager.get_secret_groups(
|
||||
all_groups = await password_manager.get_secret_groups(
|
||||
group_filter, regex=regex
|
||||
)
|
||||
ungrouped = password_manager.get_ungrouped_secrets()
|
||||
ungrouped = await password_manager.get_ungrouped_secrets()
|
||||
|
||||
all_admin_secrets = password_manager.get_available_secrets()
|
||||
all_admin_secrets = await password_manager.get_available_secrets()
|
||||
|
||||
group_result: list[ClientSecretGroup] = []
|
||||
for group in all_groups:
|
||||
@ -443,6 +484,23 @@ class AdminBackend:
|
||||
|
||||
return result
|
||||
|
||||
async def update_secret_group(
|
||||
self, group_path: str, **params: Unpack[SecretUpdateParams]
|
||||
) -> ClientSecretGroup:
|
||||
"""Update secret group."""
|
||||
async with self.secrets_manager() as password_manager:
|
||||
secret_group = await password_manager.update_group(group_path, **params)
|
||||
|
||||
all_secrets = await self.backend.get_detailed_secrets()
|
||||
secrets_mapping = {secret.name: secret for secret in all_secrets}
|
||||
return add_clients_to_secret_group(secret_group, secrets_mapping)
|
||||
|
||||
async def lookup_secret_group(self, name_path: str) -> ClientSecretGroup | None:
|
||||
"""Lookup a secret group."""
|
||||
if "/" in name_path:
|
||||
return await self.get_secret_group_by_path(name_path)
|
||||
return await self.get_secret_group(name_path)
|
||||
|
||||
async def get_secret_group(self, name: str) -> ClientSecretGroup | None:
|
||||
"""Get a single secret group by name."""
|
||||
matches = await self.get_secret_groups(group_filter=name, regex=False)
|
||||
@ -452,8 +510,8 @@ class AdminBackend:
|
||||
|
||||
async def get_secret_group_by_path(self, path: str) -> ClientSecretGroup | None:
|
||||
"""Get a group based on its path."""
|
||||
with self.password_manager() as password_manager:
|
||||
secret_group = password_manager.get_secret_group(path)
|
||||
async with self.secrets_manager() as password_manager:
|
||||
secret_group = await password_manager.get_secret_group(path)
|
||||
|
||||
if not secret_group:
|
||||
return None
|
||||
@ -476,9 +534,11 @@ class AdminBackend:
|
||||
) -> SecretView | None:
|
||||
"""Get a secret, including the actual unencrypted value and clients."""
|
||||
secret: str | None = None
|
||||
with self.password_manager() as password_manager:
|
||||
secret = password_manager.get_secret(name)
|
||||
secret_group = password_manager.get_entry_group(name)
|
||||
async with self.secrets_manager() as password_manager:
|
||||
secret = await password_manager.get_secret(name)
|
||||
secret_group: GroupReference | None = None
|
||||
if secret:
|
||||
secret_group = await password_manager.get_entry_group_info(name)
|
||||
|
||||
secret_view = SecretView(name=name, secret=secret, group=secret_group)
|
||||
|
||||
@ -488,7 +548,14 @@ class AdminBackend:
|
||||
|
||||
secret_mapping = await self.backend.get_secret(idname)
|
||||
if secret_mapping:
|
||||
secret_view.clients = [ref.name for ref in secret_mapping.clients]
|
||||
secret_view.clients = [
|
||||
ClientReference(id=ref.id, name=ref.name)
|
||||
for ref in secret_mapping.clients
|
||||
]
|
||||
|
||||
if not secret_mapping and not secret_group:
|
||||
# This secret is effectively deleted.
|
||||
return None
|
||||
|
||||
return secret_view
|
||||
|
||||
@ -503,8 +570,8 @@ class AdminBackend:
|
||||
|
||||
async def _delete_secret(self, name: str) -> None:
|
||||
"""Delete a secret."""
|
||||
with self.password_manager() as password_manager:
|
||||
password_manager.delete_entry(name)
|
||||
async with self.secrets_manager() as password_manager:
|
||||
await password_manager.delete_entry(name)
|
||||
|
||||
secret_mapping = await self.backend.get_secret(name)
|
||||
if not secret_mapping:
|
||||
@ -520,10 +587,11 @@ class AdminBackend:
|
||||
clients: list[str] | None,
|
||||
update: bool = False,
|
||||
group: str | None = None,
|
||||
distinguisher: Literal["name", "id"] = "name",
|
||||
) -> None:
|
||||
"""Add a secret."""
|
||||
with self.password_manager() as password_manager:
|
||||
password_manager.add_entry(name, value, update, group_name=group)
|
||||
async with self.secrets_manager() as password_manager:
|
||||
await password_manager.add_entry(name, value, update, group_path=group)
|
||||
|
||||
if update:
|
||||
secret_map = await self.backend.get_secret(name)
|
||||
@ -533,16 +601,20 @@ class AdminBackend:
|
||||
if not clients:
|
||||
return
|
||||
for client_name in clients:
|
||||
client = await self.get_client(client_name)
|
||||
client_id = client_name
|
||||
if distinguisher == "id":
|
||||
client_id = ("id", client_name)
|
||||
|
||||
client = await self.get_client(client_id)
|
||||
if not client:
|
||||
if update:
|
||||
raise ClientNotFoundError()
|
||||
raise ClientNotFoundError(f"Client {client_name} not found")
|
||||
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)
|
||||
LOG.info("Wrote encrypted secret for client %r", client_id)
|
||||
await self.backend.create_client_secret(client_id, name, encrypted)
|
||||
|
||||
async def add_secret(
|
||||
self,
|
||||
@ -550,14 +622,28 @@ class AdminBackend:
|
||||
value: str,
|
||||
clients: list[str] | None = None,
|
||||
group: str | None = None,
|
||||
distinguisher: Literal["name", "id"] = "name",
|
||||
) -> None:
|
||||
"""Add a secret."""
|
||||
try:
|
||||
await self._add_secret(name=name, value=value, clients=clients, group=group)
|
||||
except ClientManagementError:
|
||||
raise
|
||||
await self._add_secret(
|
||||
name=name,
|
||||
value=value,
|
||||
clients=clients,
|
||||
group=group,
|
||||
distinguisher=distinguisher,
|
||||
)
|
||||
except InvalidSecretNameError as e:
|
||||
field_error = self.create_field_error("name", str(e))
|
||||
error = self.create_validation_error(field_error)
|
||||
raise error from e
|
||||
|
||||
except ClientNotFoundError as e:
|
||||
field_error = self.create_field_error("clients", str(e))
|
||||
error = self.create_validation_error(field_error)
|
||||
raise error from e
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
raise ClientManagementError(e)
|
||||
|
||||
async def update_secret(self, name: str, value: str) -> None:
|
||||
"""Update secrets."""
|
||||
@ -576,8 +662,8 @@ class AdminBackend:
|
||||
if not client:
|
||||
raise ClientNotFoundError(client_idname)
|
||||
|
||||
with self.password_manager() as password_manager:
|
||||
secret = password_manager.get_secret(secret_name)
|
||||
async with self.secrets_manager() as password_manager:
|
||||
secret = await password_manager.get_secret(secret_name)
|
||||
if not secret:
|
||||
raise SecretNotFoundError()
|
||||
|
||||
@ -596,6 +682,10 @@ class AdminBackend:
|
||||
except Exception as e:
|
||||
raise BackendUnavailableError() from e
|
||||
|
||||
async def get_system_stats(self) -> SystemStats:
|
||||
"""Get system stats."""
|
||||
return await self.backend.get_system_stats()
|
||||
|
||||
@property
|
||||
def audit(self) -> AuditAPI:
|
||||
"""Resolve audit API."""
|
||||
@ -670,3 +760,16 @@ class AdminBackend:
|
||||
async def get_audit_log_count(self) -> int:
|
||||
"""Get audit log count."""
|
||||
return await self.audit.count()
|
||||
|
||||
def create_field_error(self, field: str, error_message: str) -> HttpErrorItem:
|
||||
"""Create a field error."""
|
||||
field_error: HttpErrorItem = {
|
||||
"loc": ["body", field],
|
||||
"msg": error_message,
|
||||
"type": "None",
|
||||
}
|
||||
return field_error
|
||||
|
||||
def create_validation_error(self, *errors: HttpErrorItem) -> BackendValidationError:
|
||||
"""Create a custom backend validation error."""
|
||||
return BackendValidationError(errors=list(errors))
|
||||
|
||||
@ -1,348 +0,0 @@
|
||||
"""Keepass password manager."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pykeepass
|
||||
import pykeepass.exceptions
|
||||
from sshecret_admin.core.settings import AdminServerSettings
|
||||
|
||||
from .models import SecretGroup
|
||||
from .master_password import decrypt_master_password
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
NO_USERNAME = "NO_USERNAME"
|
||||
|
||||
DEFAULT_LOCATION = "keepass.kdbx"
|
||||
|
||||
|
||||
class PasswordCredentialsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def create_password_db(location: Path, password: str) -> None:
|
||||
"""Create the password database."""
|
||||
LOG.info("Creating password database at %s", location)
|
||||
pykeepass.create_database(str(location.absolute()), password=password)
|
||||
|
||||
|
||||
def _kp_group_to_secret_group(
|
||||
kp_group: pykeepass.group.Group,
|
||||
parent: SecretGroup | None = None,
|
||||
depth: int | None = None,
|
||||
) -> SecretGroup:
|
||||
"""Convert keepass group to secret group dataclass."""
|
||||
group_name = cast(str, kp_group.name)
|
||||
path = "/".join(cast(list[str], kp_group.path))
|
||||
group = SecretGroup(name=group_name, path=path, description=kp_group.notes)
|
||||
for entry in kp_group.entries:
|
||||
group.entries.append(str(entry.title))
|
||||
if parent:
|
||||
group.parent_group = parent
|
||||
|
||||
current_depth = len(kp_group.path)
|
||||
|
||||
if not parent and current_depth > 1:
|
||||
parent = _kp_group_to_secret_group(kp_group.parentgroup, depth=current_depth)
|
||||
parent.children.append(group)
|
||||
group.parent_group = parent
|
||||
|
||||
if depth and depth == current_depth:
|
||||
return group
|
||||
|
||||
for subgroup in kp_group.subgroups:
|
||||
group.children.append(_kp_group_to_secret_group(subgroup, group, depth=depth))
|
||||
|
||||
return group
|
||||
|
||||
|
||||
class PasswordContext:
|
||||
"""Password Context class."""
|
||||
|
||||
def __init__(self, keepass: pykeepass.PyKeePass) -> None:
|
||||
"""Initialize password context."""
|
||||
self.keepass: pykeepass.PyKeePass = keepass
|
||||
|
||||
@property
|
||||
def _root_group(self) -> pykeepass.group.Group:
|
||||
"""Return the root group."""
|
||||
return cast(pykeepass.group.Group, self.keepass.root_group)
|
||||
|
||||
def _get_entry(self, name: str) -> pykeepass.entry.Entry | None:
|
||||
"""Get entry."""
|
||||
entry = cast(
|
||||
"pykeepass.entry.Entry | None",
|
||||
self.keepass.find_entries(title=name, first=True),
|
||||
)
|
||||
return entry
|
||||
|
||||
def _get_group(self, name: str) -> pykeepass.group.Group | None:
|
||||
"""Find a group."""
|
||||
group = cast(
|
||||
pykeepass.group.Group | None,
|
||||
self.keepass.find_groups(name=name, first=True),
|
||||
)
|
||||
return group
|
||||
|
||||
def add_entry(
|
||||
self,
|
||||
entry_name: str,
|
||||
secret: str,
|
||||
overwrite: bool = False,
|
||||
group_name: str | None = None,
|
||||
) -> None:
|
||||
"""Add an entry.
|
||||
|
||||
Specify overwrite=True to overwrite the existing secret value, if it exists.
|
||||
This will not move the entry, if the group_name is different from the original group.
|
||||
|
||||
"""
|
||||
entry = self._get_entry(entry_name)
|
||||
if entry and overwrite:
|
||||
entry.password = secret
|
||||
self.keepass.save()
|
||||
return
|
||||
|
||||
if entry:
|
||||
raise ValueError("Error: A secret with this name already exists.")
|
||||
LOG.debug("Add secret entry to keepass: %s, group: %r", entry_name, group_name)
|
||||
if group_name:
|
||||
destination_group = self._get_group(group_name)
|
||||
else:
|
||||
destination_group = self._root_group
|
||||
|
||||
entry = self.keepass.add_entry(
|
||||
destination_group=destination_group,
|
||||
title=entry_name,
|
||||
username=NO_USERNAME,
|
||||
password=secret,
|
||||
)
|
||||
self.keepass.save()
|
||||
|
||||
def get_secret(self, entry_name: str) -> str | None:
|
||||
"""Get the secret value."""
|
||||
entry = self._get_entry(entry_name)
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
LOG.warning("Secret name %s accessed", entry_name)
|
||||
if password := cast(str, entry.password):
|
||||
return str(password)
|
||||
|
||||
raise RuntimeError(f"Cannot get password for entry {entry_name}")
|
||||
|
||||
def get_entry_group(self, entry_name: str) -> str | None:
|
||||
"""Get the group for an entry."""
|
||||
entry = self._get_entry(entry_name)
|
||||
if not entry:
|
||||
return None
|
||||
if entry.group.is_root_group:
|
||||
return None
|
||||
return str(entry.group.name)
|
||||
|
||||
def get_secret_groups(
|
||||
self, pattern: str | None = None, regex: bool = True
|
||||
) -> list[SecretGroup]:
|
||||
"""Get secret groups.
|
||||
|
||||
A regex pattern may be provided to filter groups.
|
||||
"""
|
||||
if pattern:
|
||||
groups = cast(
|
||||
list[pykeepass.group.Group],
|
||||
self.keepass.find_groups(name=pattern, regex=regex),
|
||||
)
|
||||
else:
|
||||
groups = self._root_group.subgroups
|
||||
|
||||
secret_groups = [_kp_group_to_secret_group(group) for group in groups]
|
||||
return secret_groups
|
||||
|
||||
def get_secret_group_list(
|
||||
self, pattern: str | None = None, regex: bool = True
|
||||
) -> list[SecretGroup]:
|
||||
"""Get a flat list of groups."""
|
||||
if pattern:
|
||||
return self.get_secret_groups(pattern, regex)
|
||||
|
||||
groups = [group for group in self.keepass.groups if not group.is_root_group]
|
||||
secret_groups = [_kp_group_to_secret_group(group) for group in groups]
|
||||
return secret_groups
|
||||
|
||||
def get_secret_group(self, path: str) -> SecretGroup | None:
|
||||
"""Get a secret group by path."""
|
||||
elements = path.split("/")
|
||||
final_element = elements[-1]
|
||||
|
||||
current = self._root_group
|
||||
while elements:
|
||||
groupname = elements.pop(0)
|
||||
matches = [
|
||||
subgroup for subgroup in current.subgroups if subgroup.name == groupname
|
||||
]
|
||||
if matches:
|
||||
current = matches[0]
|
||||
else:
|
||||
return None
|
||||
if not current.is_root_group and current.name == final_element:
|
||||
return _kp_group_to_secret_group(current)
|
||||
return None
|
||||
|
||||
def get_ungrouped_secrets(self) -> list[str]:
|
||||
"""Get secrets without groups."""
|
||||
entries: list[str] = []
|
||||
for entry in self._root_group.entries:
|
||||
entries.append(str(entry.title))
|
||||
|
||||
return entries
|
||||
|
||||
def add_group(
|
||||
self, name: str, description: str | None = None, parent_group: str | None = None
|
||||
) -> None:
|
||||
"""Add a group."""
|
||||
kp_parent_group = self._root_group
|
||||
if parent_group:
|
||||
query = cast(
|
||||
pykeepass.group.Group | None,
|
||||
self.keepass.find_groups(name=parent_group, first=True),
|
||||
)
|
||||
if not query:
|
||||
raise ValueError(
|
||||
f"Error: Cannot find a parent group named {parent_group}"
|
||||
)
|
||||
kp_parent_group = query
|
||||
self.keepass.add_group(
|
||||
destination_group=kp_parent_group, group_name=name, notes=description
|
||||
)
|
||||
self.keepass.save()
|
||||
|
||||
def set_group_description(self, name: str, description: str) -> None:
|
||||
"""Set the description of a group."""
|
||||
group = self._get_group(name)
|
||||
if not group:
|
||||
raise ValueError(f"Error: No such group {name}")
|
||||
|
||||
group.notes = description
|
||||
self.keepass.save()
|
||||
|
||||
def set_secret_group(self, entry_name: str, group_name: str | None) -> None:
|
||||
"""Move a secret to a group.
|
||||
|
||||
If group is None, the secret will be placed in the root group.
|
||||
"""
|
||||
entry = self._get_entry(entry_name)
|
||||
if not entry:
|
||||
raise ValueError(
|
||||
f"Cannot find secret entry named {entry_name} in secrets database"
|
||||
)
|
||||
if group_name:
|
||||
group = self._get_group(group_name)
|
||||
if not group:
|
||||
raise ValueError(f"Cannot find a group named {group_name}")
|
||||
else:
|
||||
group = self._root_group
|
||||
|
||||
self.keepass.move_entry(entry, group)
|
||||
self.keepass.save()
|
||||
|
||||
def move_group(self, name: str, parent_group: str | None) -> None:
|
||||
"""Move a group.
|
||||
|
||||
If parent_group is None, it will be moved to the root.
|
||||
"""
|
||||
group = self._get_group(name)
|
||||
if not group:
|
||||
raise ValueError(f"Error: No such group {name}")
|
||||
if parent_group:
|
||||
parent = self._get_group(parent_group)
|
||||
if not parent:
|
||||
raise ValueError(f"Error: No such group {parent_group}")
|
||||
else:
|
||||
parent = self._root_group
|
||||
|
||||
self.keepass.move_group(group, parent)
|
||||
self.keepass.save()
|
||||
|
||||
def get_available_secrets(self, group_name: str | None = None) -> list[str]:
|
||||
"""Get the names of all secrets in the database."""
|
||||
if group_name:
|
||||
group = self._get_group(group_name)
|
||||
if not group:
|
||||
raise ValueError(f"Error: No such group {group_name}")
|
||||
entries = group.entries
|
||||
else:
|
||||
entries = cast(list[pykeepass.entry.Entry], self.keepass.entries)
|
||||
if not entries:
|
||||
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()
|
||||
|
||||
def delete_group(self, name: str, keep_entries: bool = True) -> None:
|
||||
"""Delete a group.
|
||||
|
||||
If keep_entries is set to False, all entries in the group will be deleted.
|
||||
"""
|
||||
group = self._get_group(name)
|
||||
if not group:
|
||||
return
|
||||
if keep_entries:
|
||||
for entry in cast(
|
||||
list[pykeepass.entry.Entry],
|
||||
self.keepass.find_entries(recursive=True, group=group),
|
||||
):
|
||||
# Move the entry to the root group.
|
||||
LOG.warning(
|
||||
"Moving orphaned secret entry %s to root group", entry.title
|
||||
)
|
||||
self.keepass.move_entry(entry, self._root_group)
|
||||
|
||||
self.keepass.delete_group(group)
|
||||
self.keepass.save()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _password_context(location: Path, password: str) -> Iterator[PasswordContext]:
|
||||
"""Open the password context."""
|
||||
try:
|
||||
database = pykeepass.PyKeePass(str(location.absolute()), password=password)
|
||||
except pykeepass.exceptions.CredentialsError as e:
|
||||
raise PasswordCredentialsError(
|
||||
"Could not open password database. Invalid credentials."
|
||||
) from e
|
||||
context = PasswordContext(database)
|
||||
yield context
|
||||
|
||||
|
||||
@contextmanager
|
||||
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 = decrypt_master_password(settings=settings, encrypted=encrypted_password)
|
||||
if not db_location.exists():
|
||||
create_password_db(db_location, password)
|
||||
|
||||
with _password_context(db_location, password) as context:
|
||||
yield context
|
||||
@ -1,86 +0,0 @@
|
||||
"""Functions related to handling the password database master password."""
|
||||
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from sshecret.crypto import (
|
||||
create_private_rsa_key,
|
||||
load_private_key,
|
||||
encrypt_string,
|
||||
decode_string,
|
||||
)
|
||||
from sshecret_admin.core.settings import AdminServerSettings
|
||||
|
||||
KEY_FILENAME = "sshecret-admin-key"
|
||||
|
||||
|
||||
def setup_master_password(
|
||||
settings: AdminServerSettings,
|
||||
filename: str = KEY_FILENAME,
|
||||
regenerate: bool = False,
|
||||
) -> str | None:
|
||||
"""Setup master password.
|
||||
|
||||
If regenerate is True, a new key will be generated.
|
||||
|
||||
This method should run just after setting up the database.
|
||||
"""
|
||||
keyfile = Path(filename)
|
||||
if settings.password_manager_directory:
|
||||
keyfile = settings.password_manager_directory / filename
|
||||
created = _initial_key_setup(settings, keyfile, regenerate)
|
||||
if not created:
|
||||
return None
|
||||
|
||||
return _generate_master_password(settings, keyfile)
|
||||
|
||||
|
||||
def decrypt_master_password(
|
||||
settings: AdminServerSettings, encrypted: str, filename: str = KEY_FILENAME
|
||||
) -> str:
|
||||
"""Retrieve master password."""
|
||||
keyfile = Path(filename)
|
||||
if settings.password_manager_directory:
|
||||
keyfile = settings.password_manager_directory / filename
|
||||
if not keyfile.exists():
|
||||
raise RuntimeError("Error: Private key has not been generated yet.")
|
||||
|
||||
private_key = load_private_key(
|
||||
str(keyfile.absolute()), password=settings.secret_key
|
||||
)
|
||||
return decode_string(encrypted, private_key)
|
||||
|
||||
|
||||
def _generate_password() -> str:
|
||||
"""Generate a password."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _initial_key_setup(
|
||||
settings: AdminServerSettings,
|
||||
keyfile: Path,
|
||||
regenerate: bool = False,
|
||||
) -> bool:
|
||||
"""Set up initial keys."""
|
||||
if keyfile.exists() and not regenerate:
|
||||
return False
|
||||
|
||||
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(settings: AdminServerSettings, keyfile: Path) -> str:
|
||||
"""Generate master password for password database.
|
||||
|
||||
Returns the encrypted string, base64 encoded.
|
||||
"""
|
||||
if not keyfile.exists():
|
||||
raise RuntimeError("Error: Private key has not been generated yet.")
|
||||
private_key = load_private_key(
|
||||
str(keyfile.absolute()), password=settings.secret_key
|
||||
)
|
||||
public_key = private_key.public_key()
|
||||
master_password = _generate_password()
|
||||
return encrypt_string(master_password, public_key)
|
||||
@ -1,7 +1,8 @@
|
||||
"""Models for the API."""
|
||||
|
||||
import secrets
|
||||
from typing import Annotated, Literal
|
||||
from typing import Annotated, Literal, Self
|
||||
import uuid
|
||||
from pydantic import (
|
||||
AfterValidator,
|
||||
BaseModel,
|
||||
@ -9,9 +10,11 @@ from pydantic import (
|
||||
Field,
|
||||
IPvAnyAddress,
|
||||
IPvAnyNetwork,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from sshecret.crypto import validate_public_key
|
||||
from sshecret.backend.models import ClientReference
|
||||
from sshecret.backend.models import AuditFilter, ClientReference
|
||||
|
||||
|
||||
def public_key_validator(value: str) -> str:
|
||||
@ -29,13 +32,26 @@ class SecretListView(BaseModel):
|
||||
clients: list[str] = Field(default_factory=list) # Clients that have access to it.
|
||||
|
||||
|
||||
class GroupReference(BaseModel):
|
||||
"""Reference to a group.
|
||||
|
||||
This will be used for references to parent groups to avoid circular
|
||||
references.
|
||||
"""
|
||||
|
||||
group_name: str
|
||||
path: str
|
||||
|
||||
|
||||
class SecretView(BaseModel):
|
||||
"""Model containing a secret, including its clear-text value."""
|
||||
|
||||
name: str
|
||||
secret: str | None
|
||||
group: str | None = None
|
||||
clients: list[str] = Field(default_factory=list) # Clients that have access to it.
|
||||
group: GroupReference | None = None
|
||||
clients: list[ClientReference] = Field(
|
||||
default_factory=list
|
||||
) # Clients that have access to it.
|
||||
|
||||
|
||||
class UpdateKeyModel(BaseModel):
|
||||
@ -62,6 +78,7 @@ class ClientCreate(BaseModel):
|
||||
"""Model to create a client."""
|
||||
|
||||
name: str
|
||||
description: str | None = None
|
||||
public_key: Annotated[str, AfterValidator(public_key_validator)]
|
||||
sources: list[IPvAnyAddress | IPvAnyNetwork] = Field(default_factory=list)
|
||||
|
||||
@ -99,6 +116,7 @@ class SecretCreate(SecretUpdate):
|
||||
clients: list[str] | None = Field(
|
||||
default=None, description="Assign the secret to a list of clients."
|
||||
)
|
||||
client_distinguisher: Literal["id", "name"] = "name"
|
||||
group: str | None = None
|
||||
|
||||
model_config: ConfigDict = ConfigDict(
|
||||
@ -123,6 +141,7 @@ class SecretCreate(SecretUpdate):
|
||||
class SecretGroup(BaseModel):
|
||||
"""A secret group."""
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
path: str
|
||||
description: str | None = None
|
||||
@ -142,24 +161,114 @@ class SecretClientMapping(BaseModel):
|
||||
class ClientSecretGroup(BaseModel):
|
||||
"""Client secrets grouped."""
|
||||
|
||||
id: uuid.UUID
|
||||
group_name: str
|
||||
path: str
|
||||
description: str | None = None
|
||||
parent_group: "ClientSecretGroup | None" = None
|
||||
parent_group: GroupReference | None = None
|
||||
children: list["ClientSecretGroup"] = Field(default_factory=list)
|
||||
entries: list[SecretClientMapping] = Field(default_factory=list)
|
||||
|
||||
def reference(self) -> GroupReference:
|
||||
"""Create a reference."""
|
||||
return GroupReference(group_name=self.group_name, path=self.path)
|
||||
|
||||
|
||||
class SecretGroupCreate(BaseModel):
|
||||
"""Create model for creating secret groups."""
|
||||
|
||||
name: str
|
||||
name: str = Field(min_length=1) # blank group names are a pain!
|
||||
description: str | None = None
|
||||
parent_group: str | None = None
|
||||
|
||||
|
||||
class SecretGroupUdate(BaseModel):
|
||||
"""Update model for updating secret groups."""
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
parent_group: str | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, value: str | None) -> str | None:
|
||||
"""Validate name."""
|
||||
if not value:
|
||||
return None
|
||||
if "/" in value:
|
||||
raise ValueError("Name cannot be a path")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class ClientSecretGroupList(BaseModel):
|
||||
"""Secret group list."""
|
||||
|
||||
ungrouped: list[SecretClientMapping] = Field(default_factory=list)
|
||||
groups: list[ClientSecretGroup] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ClientListParams(BaseModel):
|
||||
"""Client list parameters."""
|
||||
|
||||
limit: int = Field(100, gt=0, le=100)
|
||||
offset: int = Field(0, ge=0)
|
||||
id: uuid.UUID | None = None
|
||||
name: str | None = None
|
||||
name__like: str | None = None
|
||||
name__contains: str | None = None
|
||||
order_by: str = "created_at"
|
||||
order_reverse: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_expressions(self) -> Self:
|
||||
"""Validate mutually exclusive expression."""
|
||||
name_filter = False
|
||||
if self.name__like or self.name__contains:
|
||||
name_filter = True
|
||||
if self.name__like and self.name__contains:
|
||||
raise ValueError("You may only specify one name expression")
|
||||
if self.name and name_filter:
|
||||
raise ValueError(
|
||||
"You must either specify name or one of name__like or name__contains"
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class SecretGroupAssign(BaseModel):
|
||||
"""Model for assigning secrets to a group.
|
||||
|
||||
If group is None, then it will be placed in the root.
|
||||
"""
|
||||
|
||||
secret_name: str
|
||||
group_path: str | None
|
||||
|
||||
|
||||
class GroupPath(BaseModel):
|
||||
"""Path to a group."""
|
||||
|
||||
path: str = Field(pattern="^/.*")
|
||||
|
||||
|
||||
class AuditQueryFilter(AuditFilter):
|
||||
"""Audit query filter."""
|
||||
|
||||
offset: int = 0
|
||||
limit: int = 100
|
||||
|
||||
|
||||
class UserPasswordChange(BaseModel):
|
||||
"""Model for changing the password of a user."""
|
||||
|
||||
current_password: str
|
||||
new_password: str
|
||||
new_password_confirm: str
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_passwords(self) -> Self:
|
||||
"""Validate that the passwords match."""
|
||||
if self.new_password != self.new_password_confirm:
|
||||
raise ValueError("Passwords don't match")
|
||||
return self
|
||||
|
||||
@ -0,0 +1,888 @@
|
||||
"""Rewritten secret manager using a rsa keys."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import NotRequired, TypedDict, Unpack
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, aliased
|
||||
from sshecret.backend import SshecretBackend
|
||||
from sshecret.backend.api import AuditAPI
|
||||
from sshecret.backend.identifiers import KeySpec
|
||||
from sshecret.backend.models import Client, ClientSecret, Operation, SubSystem
|
||||
from sshecret.crypto import (
|
||||
create_private_rsa_key,
|
||||
decode_string,
|
||||
encrypt_string,
|
||||
generate_public_key_string,
|
||||
load_private_key,
|
||||
load_public_key,
|
||||
)
|
||||
from sshecret_admin.auth import PasswordDB
|
||||
from sshecret_admin.auth.models import Group, ManagedSecret
|
||||
from sshecret_admin.core.db import DatabaseSessionManager
|
||||
from sshecret_admin.core.settings import AdminServerSettings
|
||||
from sshecret_admin.services.models import GroupReference, SecretGroup
|
||||
|
||||
|
||||
KEY_FILENAME = "sshecret-admin-key"
|
||||
PASSWORD_MANAGER_ID = "SshecretAdminPasswordManager"
|
||||
|
||||
LOG = logging.getLogger(PASSWORD_MANAGER_ID)
|
||||
|
||||
|
||||
class SecretManagerError(Exception):
|
||||
"""Secret manager error."""
|
||||
|
||||
|
||||
class InvalidGroupNameError(SecretManagerError):
|
||||
"""Invalid group name."""
|
||||
|
||||
|
||||
class InvalidSecretNameError(SecretManagerError):
|
||||
"""Invalid secret name."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientAuditData:
|
||||
"""Client audit data."""
|
||||
|
||||
username: str
|
||||
origin: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedPath:
|
||||
"""Parsed path."""
|
||||
|
||||
item: str
|
||||
full_path: str
|
||||
parent: str | None = None
|
||||
|
||||
|
||||
class SecretDataEntryExport(BaseModel):
|
||||
"""Exportable secret entry."""
|
||||
|
||||
name: str
|
||||
secret: str
|
||||
group: str | None = None
|
||||
|
||||
|
||||
class SecretDataGroupExport(BaseModel):
|
||||
"""Exportable secret grouping."""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class SecretDataExport(BaseModel):
|
||||
"""Exportable object containing secrets and groups."""
|
||||
|
||||
entries: list[SecretDataEntryExport]
|
||||
groups: list[SecretDataGroupExport]
|
||||
|
||||
|
||||
class SecretUpdateParams(TypedDict):
|
||||
"""Secret update parameters."""
|
||||
|
||||
name: NotRequired[str]
|
||||
description: NotRequired[str]
|
||||
parent: NotRequired[str]
|
||||
|
||||
|
||||
def split_path(path: str) -> list[str]:
|
||||
"""Split a path into a list of groups."""
|
||||
elements = path.split("/")
|
||||
if path.startswith("/"):
|
||||
elements = elements[1:]
|
||||
|
||||
return elements
|
||||
|
||||
|
||||
def parse_path(path: str) -> ParsedPath:
|
||||
"""Parse path."""
|
||||
elements = split_path(path)
|
||||
parsed = ParsedPath(elements[-1], path)
|
||||
if len(elements) > 1:
|
||||
parsed.parent = elements[-2]
|
||||
return parsed
|
||||
|
||||
|
||||
class AsyncSecretContext:
|
||||
"""Async secret context."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
private_key: rsa.RSAPrivateKey,
|
||||
manager_client: Client,
|
||||
session: AsyncSession,
|
||||
backend: SshecretBackend,
|
||||
audit_data: ClientAuditData,
|
||||
) -> None:
|
||||
"""Initialize secret manager"""
|
||||
self._private_key: rsa.RSAPrivateKey = private_key
|
||||
self._manager_client: Client = manager_client
|
||||
self._id: KeySpec = ("id", str(manager_client.id))
|
||||
self.backend: SshecretBackend = backend
|
||||
self.session: AsyncSession = session
|
||||
|
||||
self.audit_data: ClientAuditData = audit_data
|
||||
self.audit: AuditAPI = backend.audit(SubSystem.ADMIN)
|
||||
self._import_has_run: bool = False
|
||||
|
||||
async def _create_missing_entries(self) -> None:
|
||||
"""Create any missing entries."""
|
||||
new_secrets: bool = False
|
||||
to_check = set(self._manager_client.secrets)
|
||||
for secret_name in to_check:
|
||||
# entry = await self._get_entry(secret_name, include_deleted=True)
|
||||
statement = select(ManagedSecret).where(ManagedSecret.name == secret_name)
|
||||
result = await self.session.scalars(statement)
|
||||
if not result.first():
|
||||
new_secrets = True
|
||||
managed_secret = ManagedSecret(name=secret_name)
|
||||
self.session.add(managed_secret)
|
||||
|
||||
await self.session.flush()
|
||||
await self.write_audit(
|
||||
Operation.CREATE,
|
||||
message="Imported managed secret from backend.",
|
||||
secret_name=secret_name,
|
||||
managed_secret=managed_secret,
|
||||
)
|
||||
if new_secrets:
|
||||
await self.session.commit()
|
||||
|
||||
async def _get_group_depth(self, group: Group) -> int:
|
||||
"""Get the depth of a group."""
|
||||
depth = 1
|
||||
if not group.parent_id:
|
||||
return depth
|
||||
|
||||
current = group
|
||||
while current.parent is not None:
|
||||
if current.parent:
|
||||
depth += 1
|
||||
current = await self._get_group_by_id(current.parent.id)
|
||||
else:
|
||||
break
|
||||
|
||||
return depth
|
||||
|
||||
async def _get_group_path(self, group: Group) -> str:
|
||||
"""Get the path of a group."""
|
||||
|
||||
if not group.parent_id:
|
||||
return group.name
|
||||
path: list[str] = []
|
||||
current = group
|
||||
while current.parent_id is not None:
|
||||
path.append(current.name)
|
||||
current = await self._get_group_by_id(current.parent_id)
|
||||
|
||||
path.append("")
|
||||
path.reverse()
|
||||
return "/".join(path)
|
||||
|
||||
async def _get_group_secrets(self, group: Group) -> list[ManagedSecret]:
|
||||
"""Get secrets in a group."""
|
||||
statement = (
|
||||
select(ManagedSecret)
|
||||
.where(ManagedSecret.group_id == group.id)
|
||||
.where(ManagedSecret.is_deleted.is_not(True))
|
||||
)
|
||||
results = await self.session.scalars(statement)
|
||||
return list(results.all())
|
||||
|
||||
async def _build_group_tree(
|
||||
self, group: Group, parent: SecretGroup | None = None, depth: int | None = None
|
||||
) -> SecretGroup:
|
||||
"""Build a group tree."""
|
||||
path = "/"
|
||||
if parent:
|
||||
path = parent.path
|
||||
|
||||
path = os.path.join(path, group.name)
|
||||
secret_group = SecretGroup(
|
||||
id=group.id, name=group.name, path=path, description=group.description
|
||||
)
|
||||
group_secrets = await self._get_group_secrets(group)
|
||||
for secret in group_secrets:
|
||||
secret_group.entries.append(secret.name)
|
||||
if parent:
|
||||
secret_group.parent_group = parent
|
||||
|
||||
current_depth = await self._get_group_depth(group)
|
||||
|
||||
if not parent and group.parent:
|
||||
parent_group = await self._get_group_by_id(group.parent.id)
|
||||
assert parent_group is not None
|
||||
parent = await self._build_group_tree(parent_group, depth=current_depth)
|
||||
path = os.path.join(parent.path, group.name)
|
||||
secret_group.path = path
|
||||
parent.children.append(secret_group)
|
||||
secret_group.parent_group = parent
|
||||
|
||||
if depth and depth == current_depth:
|
||||
return secret_group
|
||||
|
||||
for subgroup in group.children:
|
||||
LOG.debug(
|
||||
"group: %s, subgroup: %s path=%r, group_path: %r, parent: %r",
|
||||
group.name,
|
||||
subgroup.name,
|
||||
path,
|
||||
secret_group.path,
|
||||
bool(parent),
|
||||
)
|
||||
child_group = await self._get_group_by_id(subgroup.id)
|
||||
assert child_group is not None
|
||||
secret_subgroup = await self._build_group_tree(
|
||||
child_group, secret_group, depth=depth
|
||||
)
|
||||
secret_group.children.append(secret_subgroup)
|
||||
|
||||
return secret_group
|
||||
|
||||
async def write_audit(
|
||||
self,
|
||||
operation: Operation,
|
||||
message: str,
|
||||
group_name: str | None = None,
|
||||
client_secret: ClientSecret | None = None,
|
||||
secret_name: str | None = None,
|
||||
managed_secret: ManagedSecret | None = None,
|
||||
**data: str,
|
||||
) -> None:
|
||||
"""Write Audit message."""
|
||||
if group_name:
|
||||
data["group"] = group_name
|
||||
|
||||
data["username"] = self.audit_data.username
|
||||
if client_secret and not secret_name:
|
||||
secret_name = client_secret.name
|
||||
|
||||
if managed_secret:
|
||||
data["managed_secret"] = str(managed_secret.id)
|
||||
|
||||
await self.audit.write_async(
|
||||
operation=operation,
|
||||
message=message,
|
||||
origin=self.audit_data.origin,
|
||||
client=self._manager_client,
|
||||
secret=client_secret,
|
||||
secret_name=secret_name,
|
||||
**data,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def public_key(self) -> rsa.RSAPublicKey:
|
||||
"""Get public key."""
|
||||
keystring = self._manager_client.public_key
|
||||
return load_public_key(keystring.encode())
|
||||
|
||||
async def _get_entry(
|
||||
self, name: str, include_deleted: bool = False
|
||||
) -> ManagedSecret | None:
|
||||
"""Get managed secret."""
|
||||
if not self._import_has_run:
|
||||
await self._create_missing_entries()
|
||||
self._import_has_run = True
|
||||
statement = (
|
||||
select(ManagedSecret)
|
||||
.options(selectinload(ManagedSecret.group))
|
||||
.where(ManagedSecret.name == name)
|
||||
)
|
||||
if not include_deleted:
|
||||
statement = statement.where(ManagedSecret.is_deleted.is_not(True))
|
||||
|
||||
result = await self.session.scalars(statement)
|
||||
return result.first()
|
||||
|
||||
async def add_entry(
|
||||
self,
|
||||
entry_name: str,
|
||||
secret: str,
|
||||
overwrite: bool = False,
|
||||
group_path: str | None = None,
|
||||
) -> None:
|
||||
"""Add entry."""
|
||||
existing_entry = await self._get_entry(entry_name)
|
||||
if existing_entry and not overwrite:
|
||||
raise InvalidSecretNameError(
|
||||
"Another secret with this name is already defined."
|
||||
)
|
||||
|
||||
encrypted = encrypt_string(secret, self.public_key)
|
||||
client_secret = await self.backend.create_client_secret(
|
||||
self._id, entry_name, encrypted
|
||||
)
|
||||
group_id: uuid.UUID | None = None
|
||||
if group_path:
|
||||
elements = parse_path(group_path)
|
||||
group = await self._get_group(elements.item, elements.parent, True)
|
||||
if not group:
|
||||
raise InvalidGroupNameError("Invalid group name")
|
||||
group_id = group.id
|
||||
|
||||
if existing_entry:
|
||||
existing_entry.updated_at = datetime.now(timezone.utc)
|
||||
if group_id:
|
||||
existing_entry.group_id = group_id
|
||||
self.session.add(existing_entry)
|
||||
await self.session.commit()
|
||||
await self.write_audit(
|
||||
Operation.UPDATE,
|
||||
"Updated secret value",
|
||||
group_name=group_path,
|
||||
client_secret=client_secret,
|
||||
managed_secret=existing_entry,
|
||||
)
|
||||
else:
|
||||
managed_secret = ManagedSecret(
|
||||
name=entry_name,
|
||||
group_id=group_id,
|
||||
)
|
||||
self.session.add(managed_secret)
|
||||
|
||||
await self.session.commit()
|
||||
await self.write_audit(
|
||||
Operation.CREATE,
|
||||
"Created managed client secret",
|
||||
group_path,
|
||||
client_secret=client_secret,
|
||||
managed_secret=managed_secret,
|
||||
)
|
||||
|
||||
async def get_secret(self, entry_name: str) -> str | None:
|
||||
"""Get secret."""
|
||||
client_secret = await self.backend.get_client_secret(
|
||||
self._id, ("name", entry_name)
|
||||
)
|
||||
if not client_secret:
|
||||
return None
|
||||
decrypted = decode_string(client_secret, self._private_key)
|
||||
await self.write_audit(
|
||||
Operation.READ,
|
||||
"Secret was viewed from secret manager",
|
||||
secret_name=entry_name,
|
||||
)
|
||||
|
||||
return decrypted
|
||||
|
||||
async def get_available_secrets(self, group_path: str | None = None) -> list[str]:
|
||||
"""Get the names of all secrets in the db."""
|
||||
if not self._import_has_run:
|
||||
await self._create_missing_entries()
|
||||
if group_path:
|
||||
elements = parse_path(group_path)
|
||||
group = await self._get_group(elements.item, elements.parent)
|
||||
if not group:
|
||||
raise InvalidGroupNameError("Invalid or nonexisting group name.")
|
||||
entries = group.secrets
|
||||
else:
|
||||
result = await self.session.scalars(
|
||||
select(ManagedSecret)
|
||||
.options(selectinload(ManagedSecret.group))
|
||||
.where(ManagedSecret.is_deleted.is_not(True))
|
||||
)
|
||||
|
||||
entries = list(result.all())
|
||||
|
||||
return [entry.name for entry in entries]
|
||||
|
||||
async def delete_entry(self, entry_name: str) -> None:
|
||||
"""Delete a secret."""
|
||||
entry = await self._get_entry(entry_name)
|
||||
if not entry:
|
||||
return
|
||||
entry.is_deleted = True
|
||||
entry.deleted_at = datetime.now(timezone.utc)
|
||||
self.session.add(entry)
|
||||
await self.session.commit()
|
||||
await self.backend.delete_client_secret(
|
||||
("id", str(self._manager_client.id)), ("name", entry_name)
|
||||
)
|
||||
await self.write_audit(
|
||||
Operation.DELETE,
|
||||
"Managed secret entry deleted",
|
||||
secret_name=entry_name,
|
||||
managed_secret=entry,
|
||||
)
|
||||
|
||||
async def get_entry_group(self, entry_name: str) -> str | None:
|
||||
"""Get group of entry."""
|
||||
entry = await self._get_entry(entry_name)
|
||||
if not entry:
|
||||
raise InvalidSecretNameError("Invalid secret name or secret not found.")
|
||||
if entry.group:
|
||||
return entry.group.name
|
||||
return None
|
||||
|
||||
async def get_entry_group_info(self, entry_name: str) -> GroupReference | None:
|
||||
"""Get group of entry, with path."""
|
||||
entry = await self._get_entry(entry_name)
|
||||
if not entry:
|
||||
raise InvalidSecretNameError("Invalid secret name or secret not found.")
|
||||
if not entry.group:
|
||||
return None
|
||||
group = await self._get_group_by_id(entry.group.id)
|
||||
group_tree = await self._build_group_tree(group)
|
||||
return GroupReference(group_name=entry.group.name, path=group_tree.path)
|
||||
|
||||
async def _get_groups(
|
||||
self, pattern: str | None = None, regex: bool = True, root_groups: bool = False
|
||||
) -> list[Group]:
|
||||
"""Get groups."""
|
||||
statement = select(Group).options(
|
||||
selectinload(Group.children), selectinload(Group.parent)
|
||||
)
|
||||
if pattern and regex:
|
||||
statement = statement.where(Group.name.regexp_match(pattern))
|
||||
elif pattern:
|
||||
statement = statement.where(Group.name.contains(pattern))
|
||||
if root_groups:
|
||||
statement = statement.where(Group.parent_id == None)
|
||||
results = await self.session.scalars(statement)
|
||||
return list(results.all())
|
||||
|
||||
async def get_secret_groups(
|
||||
self, pattern: str | None = None, regex: bool = True
|
||||
) -> list[SecretGroup]:
|
||||
"""Get secret groups, as a hierarcy."""
|
||||
if pattern:
|
||||
groups = await self._get_groups(pattern, regex)
|
||||
else:
|
||||
groups = await self._get_groups(root_groups=True)
|
||||
|
||||
secret_groups: list[SecretGroup] = []
|
||||
for group in groups:
|
||||
secret_group = await self._build_group_tree(group)
|
||||
secret_groups.append(secret_group)
|
||||
|
||||
return secret_groups
|
||||
|
||||
async def get_secret_group_list(
|
||||
self, pattern: str | None = None, regex: bool = True
|
||||
) -> list[SecretGroup]:
|
||||
"""Get secret group list."""
|
||||
groups = await self._get_groups(pattern, regex)
|
||||
return [(await self._build_group_tree(group)) for group in groups]
|
||||
|
||||
async def _get_group_by_id(self, id: uuid.UUID) -> Group:
|
||||
"""Get group by ID."""
|
||||
statement = (
|
||||
select(Group)
|
||||
.options(
|
||||
selectinload(Group.parent),
|
||||
selectinload(Group.children),
|
||||
selectinload(Group.secrets),
|
||||
)
|
||||
.where(Group.id == id)
|
||||
)
|
||||
|
||||
result = await self.session.scalars(statement)
|
||||
return result.one()
|
||||
|
||||
async def _lookup_group(self, name_path: str) -> Group | None:
|
||||
"""Lookup group by path."""
|
||||
if "/" in name_path:
|
||||
elements = parse_path(name_path)
|
||||
return await self._get_group(elements.item, elements.parent)
|
||||
return await self._get_group(name_path)
|
||||
|
||||
async def _get_group(
|
||||
self, name: str, parent: str | None = None, exact_match: bool = False
|
||||
) -> Group | None:
|
||||
"""Get a group."""
|
||||
statement = (
|
||||
select(Group)
|
||||
.options(
|
||||
selectinload(Group.parent),
|
||||
selectinload(Group.children),
|
||||
selectinload(Group.secrets),
|
||||
)
|
||||
.where(Group.name == name)
|
||||
)
|
||||
if parent:
|
||||
ParentGroup = aliased(Group)
|
||||
statement = statement.join(ParentGroup, Group.parent).where(
|
||||
ParentGroup.name == parent
|
||||
)
|
||||
elif exact_match:
|
||||
statement = statement.where(Group.parent_id == None)
|
||||
result = await self.session.scalars(statement)
|
||||
return result.first()
|
||||
|
||||
async def get_secret_group(self, path: str) -> SecretGroup | None:
|
||||
"""Get a secret group by path."""
|
||||
elements = parse_path(path)
|
||||
|
||||
group_name = elements.item
|
||||
parent_group = elements.parent
|
||||
|
||||
group = await self._get_group(group_name, parent_group)
|
||||
if not group:
|
||||
return None
|
||||
|
||||
return await self._build_group_tree(group)
|
||||
|
||||
async def get_ungrouped_secrets(self) -> list[str]:
|
||||
"""Get ungrouped secrets."""
|
||||
statement = (
|
||||
select(ManagedSecret)
|
||||
.where(ManagedSecret.is_deleted.is_not(True))
|
||||
.where(ManagedSecret.group_id == None)
|
||||
)
|
||||
result = await self.session.scalars(statement)
|
||||
secrets = result.all()
|
||||
return [secret.name for secret in secrets]
|
||||
|
||||
async def add_group(
|
||||
self,
|
||||
name_or_path: str,
|
||||
description: str | None = None,
|
||||
parent_group: str | None = None,
|
||||
) -> None:
|
||||
"""Add a group."""
|
||||
parent_id: uuid.UUID | None = None
|
||||
group_name = name_or_path
|
||||
if parent_group and name_or_path.startswith("/"):
|
||||
raise InvalidGroupNameError(
|
||||
"Path as name cannot be used if parent is also specified."
|
||||
)
|
||||
if name_or_path.startswith("/"):
|
||||
elements = parse_path(name_or_path)
|
||||
group_name = elements.item
|
||||
parent_group = elements.parent
|
||||
|
||||
if parent_group:
|
||||
if parent := (await self._lookup_group(parent_group)):
|
||||
child_names = [child.name for child in parent.children]
|
||||
if group_name in child_names:
|
||||
raise InvalidGroupNameError(
|
||||
"Parent group already has a group with this name."
|
||||
)
|
||||
parent_id = parent.id
|
||||
|
||||
else:
|
||||
raise InvalidGroupNameError(
|
||||
"Invalid or non-existing parent group name."
|
||||
)
|
||||
else:
|
||||
existing_group = await self._get_group(group_name)
|
||||
if existing_group:
|
||||
raise InvalidGroupNameError("A group with this name already exists.")
|
||||
|
||||
group = Group(
|
||||
name=group_name,
|
||||
description=description,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
self.session.add(group)
|
||||
# We don't audit-log this operation.
|
||||
await self.session.commit()
|
||||
|
||||
async def update_group(
|
||||
self, name_path: str, **params: Unpack[SecretUpdateParams]
|
||||
) -> SecretGroup:
|
||||
"""Perform a complete update of a group.
|
||||
|
||||
This allows a patch operation. Only keyword arguments added will be considered.
|
||||
"""
|
||||
group = await self._lookup_group(name_path)
|
||||
|
||||
if not group:
|
||||
raise InvalidGroupNameError("Invalid or non-existing parent group name.")
|
||||
if description := params.get("description"):
|
||||
group.description = description
|
||||
|
||||
target_name = group.name
|
||||
rename = False
|
||||
if new_name := params.get("name"):
|
||||
target_name = new_name
|
||||
if target_name != group.name:
|
||||
rename = True
|
||||
|
||||
parent_group: Group | None = None
|
||||
move_to_root = False
|
||||
if parent := params.get("parent"):
|
||||
if parent == "/":
|
||||
group.parent = None
|
||||
move_to_root = True
|
||||
if rename:
|
||||
groups = await self._get_groups(root_groups=True)
|
||||
root_names = [x.name for x in groups]
|
||||
if target_name in root_names:
|
||||
raise InvalidGroupNameError("Name is already in use")
|
||||
|
||||
else:
|
||||
new_parent_group = await self._lookup_group(parent)
|
||||
if not new_parent_group:
|
||||
raise InvalidGroupNameError(
|
||||
"Invalid or non-existing parent group name."
|
||||
)
|
||||
parent_group = new_parent_group
|
||||
group.parent_id = new_parent_group.id
|
||||
elif group.parent_id and not move_to_root:
|
||||
parent_group = await self._get_group_by_id(group.parent_id)
|
||||
|
||||
if parent_group and rename and not move_to_root:
|
||||
child_names = [child.name for child in parent_group.children]
|
||||
if target_name in child_names:
|
||||
raise InvalidGroupNameError(
|
||||
f"Parent group {parent_group.name} already has a group with this name: {target_name}. Params: {params !r}"
|
||||
)
|
||||
group.name = target_name
|
||||
|
||||
self.session.add(group)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(group, ["parent"])
|
||||
return await self._build_group_tree(group)
|
||||
|
||||
async def set_group_description(self, path: str, description: str) -> None:
|
||||
"""Set group description."""
|
||||
elements = parse_path(path)
|
||||
group = await self._get_group(elements.item, elements.parent, True)
|
||||
if not group:
|
||||
raise InvalidGroupNameError("Invalid or non-existing group name.")
|
||||
group.description = description
|
||||
self.session.add(group)
|
||||
await self.session.commit()
|
||||
|
||||
async def set_secret_group(self, entry_name: str, group_name: str | None) -> None:
|
||||
"""Move a secret to a group.
|
||||
|
||||
If group_name is None, the secret will be moved out of any group it may exist in.
|
||||
"""
|
||||
entry = await self._get_entry(entry_name)
|
||||
if not entry:
|
||||
raise InvalidSecretNameError("Invalid or non-existing secret.")
|
||||
if group_name:
|
||||
elements = parse_path(group_name)
|
||||
group = await self._get_group(elements.item, elements.parent, True)
|
||||
if not group:
|
||||
raise InvalidGroupNameError("Invalid or non-existing group name.")
|
||||
entry.group_id = group.id
|
||||
else:
|
||||
entry.group_id = None
|
||||
|
||||
self.session.add(entry)
|
||||
await self.session.commit()
|
||||
await self.write_audit(
|
||||
Operation.UPDATE,
|
||||
"Secret group updated",
|
||||
group_name=group_name or "ROOT",
|
||||
secret_name=entry_name,
|
||||
managed_secret=entry,
|
||||
)
|
||||
|
||||
async def move_group(self, path: str, parent_group: str | None) -> str:
|
||||
"""Move group.
|
||||
|
||||
If parent_group is None, it will be moved to the root.
|
||||
"""
|
||||
LOG.info("Move group: %s => %s", path, parent_group)
|
||||
elements = parse_path(path)
|
||||
group = await self._get_group(elements.item, elements.parent, True)
|
||||
if not group:
|
||||
raise InvalidGroupNameError("Invalid or non-existing group name.")
|
||||
|
||||
parent_group_id: uuid.UUID | None = None
|
||||
if parent_group:
|
||||
db_parent_group = await self._lookup_group(parent_group)
|
||||
if not db_parent_group:
|
||||
raise InvalidGroupNameError("Invalid or non-existing parent group.")
|
||||
parent_group_id = db_parent_group.id
|
||||
|
||||
group.parent_id = parent_group_id
|
||||
|
||||
self.session.add(group)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(group)
|
||||
new_path = await self._get_group_path(group)
|
||||
return new_path
|
||||
|
||||
async def delete_group(self, path: str) -> None:
|
||||
"""Delete a group."""
|
||||
elements = parse_path(path)
|
||||
group = await self._get_group(elements.item, elements.parent, True)
|
||||
if not group:
|
||||
return
|
||||
await self.session.delete(group)
|
||||
|
||||
await self.session.commit()
|
||||
# We don't audit-log this operation currently, even though it indirectly
|
||||
# may affect secrets.
|
||||
|
||||
async def delete_group_id(self, id: str | uuid.UUID) -> None:
|
||||
"""Delete a group by ID."""
|
||||
if isinstance(id, str):
|
||||
id = uuid.UUID(id)
|
||||
group = await self._get_group_by_id(id)
|
||||
if not group:
|
||||
raise InvalidGroupNameError("Invalid or non-existing group ID.")
|
||||
await self.session.delete(group)
|
||||
|
||||
await self.session.commit()
|
||||
|
||||
async def _export_entries(self) -> list[SecretDataEntryExport]:
|
||||
"""Export entries as a pydantic object."""
|
||||
statement = (
|
||||
select(ManagedSecret)
|
||||
.options(selectinload(ManagedSecret.group))
|
||||
.where(ManagedSecret.is_deleted.is_(False))
|
||||
)
|
||||
results = await self.session.scalars(statement)
|
||||
entries: list[SecretDataEntryExport] = []
|
||||
for entry in results.all():
|
||||
group: str | None = None
|
||||
if entry.group:
|
||||
group = await self._get_group_path(entry.group)
|
||||
secret = await self.get_secret(entry.name)
|
||||
if not secret:
|
||||
continue
|
||||
data = SecretDataEntryExport(name=entry.name, secret=secret, group=group)
|
||||
entries.append(data)
|
||||
return entries
|
||||
|
||||
async def _export_groups(self) -> list[SecretDataGroupExport]:
|
||||
"""Export groups as pydantic objects."""
|
||||
groups = await self.get_secret_group_list()
|
||||
entries = [
|
||||
SecretDataGroupExport(
|
||||
name=group.name,
|
||||
path=group.path,
|
||||
description=group.description,
|
||||
)
|
||||
for group in groups
|
||||
]
|
||||
return entries
|
||||
|
||||
async def export_secrets(self) -> SecretDataExport:
|
||||
"""Export the managed secrets as a pydantic object."""
|
||||
entries = await self._export_entries()
|
||||
groups = await self._export_groups()
|
||||
return SecretDataExport(entries=entries, groups=groups)
|
||||
|
||||
async def export_secrets_json(self) -> str:
|
||||
"""Export secrets as JSON."""
|
||||
export = await self.export_secrets()
|
||||
return export.model_dump_json(indent=2)
|
||||
|
||||
|
||||
def get_managed_private_key(
|
||||
settings: AdminServerSettings,
|
||||
filename: str = KEY_FILENAME,
|
||||
regenerate: bool = False,
|
||||
) -> rsa.RSAPrivateKey:
|
||||
"""Load our private key."""
|
||||
keyfile = Path(filename)
|
||||
if settings.password_manager_directory:
|
||||
keyfile = settings.password_manager_directory / filename
|
||||
if not keyfile.exists():
|
||||
_initial_key_setup(settings, keyfile)
|
||||
setup_password_manager(settings, keyfile, regenerate)
|
||||
return load_private_key(str(keyfile.absolute()), password=settings.secret_key)
|
||||
|
||||
|
||||
def setup_password_manager(
|
||||
settings: AdminServerSettings, filename: Path, regenerate: bool = False
|
||||
) -> bool:
|
||||
"""Setup password manager."""
|
||||
if filename.exists() and not regenerate:
|
||||
return False
|
||||
|
||||
if not settings.secret_key:
|
||||
raise RuntimeError("Error: Could not load secret key from environment.")
|
||||
create_private_rsa_key(filename, password=settings.secret_key)
|
||||
return True
|
||||
|
||||
|
||||
async def create_manager_client(
|
||||
backend: SshecretBackend, public_key: rsa.RSAPublicKey
|
||||
) -> Client:
|
||||
"""Create the manager client."""
|
||||
public_key_string = generate_public_key_string(public_key)
|
||||
new_client = await backend.create_system_client(
|
||||
"AdminPasswordManager",
|
||||
public_key_string,
|
||||
)
|
||||
return new_client
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def password_manager_context(
|
||||
settings: AdminServerSettings, username: str, origin: str
|
||||
) -> AsyncIterator[AsyncSecretContext]:
|
||||
"""Start a context for the password manager."""
|
||||
audit_context_data = ClientAuditData(username=username, origin=origin)
|
||||
session_manager = DatabaseSessionManager(settings.async_db_url)
|
||||
backend = SshecretBackend(str(settings.backend_url), settings.backend_token)
|
||||
private_key = get_managed_private_key(settings)
|
||||
async with session_manager.session() as session:
|
||||
# Check if there is a client_id stored already.
|
||||
query = select(PasswordDB).where(PasswordDB.id == 1)
|
||||
result = await session.scalars(query)
|
||||
password_db = result.first()
|
||||
if not password_db:
|
||||
password_db = PasswordDB(id=1)
|
||||
session.add(password_db)
|
||||
await session.flush()
|
||||
if not password_db.client_id:
|
||||
manager_client = await create_manager_client(
|
||||
backend, private_key.public_key()
|
||||
)
|
||||
password_db.client_id = manager_client.id
|
||||
session.add(password_db)
|
||||
await session.commit()
|
||||
else:
|
||||
manager_client = await backend.get_client(
|
||||
("id", str(password_db.client_id))
|
||||
)
|
||||
if not manager_client:
|
||||
raise SecretManagerError("Error: Could not fetch system client.")
|
||||
|
||||
context = AsyncSecretContext(
|
||||
private_key, manager_client, session, backend, audit_context_data
|
||||
)
|
||||
yield context
|
||||
|
||||
|
||||
def setup_private_key(
|
||||
settings: AdminServerSettings,
|
||||
filename: str = KEY_FILENAME,
|
||||
regenerate: bool = False,
|
||||
) -> None:
|
||||
"""Setup secret manager private key."""
|
||||
keyfile = Path(filename)
|
||||
if settings.password_manager_directory:
|
||||
keyfile = settings.password_manager_directory / filename
|
||||
_initial_key_setup(settings, keyfile, regenerate)
|
||||
|
||||
|
||||
def _initial_key_setup(
|
||||
settings: AdminServerSettings,
|
||||
keyfile: Path,
|
||||
regenerate: bool = False,
|
||||
) -> bool:
|
||||
"""Set up initial keys."""
|
||||
if keyfile.exists() and not regenerate:
|
||||
return False
|
||||
|
||||
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
|
||||
@ -1,30 +0,0 @@
|
||||
@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";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,143 +0,0 @@
|
||||
/* PrismJS 1.30.0
|
||||
https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript */
|
||||
/**
|
||||
* prism.js default theme for JavaScript, CSS and HTML
|
||||
* Based on dabblet (http://dabblet.com)
|
||||
* @author Lea Verou
|
||||
*/
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
color: black;
|
||||
background: none;
|
||||
text-shadow: 0 1px white;
|
||||
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;
|
||||
}
|
||||
|
||||
pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection,
|
||||
code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection {
|
||||
text-shadow: none;
|
||||
background: #b3d4fc;
|
||||
}
|
||||
|
||||
pre[class*="language-"]::selection, pre[class*="language-"] ::selection,
|
||||
code[class*="language-"]::selection, code[class*="language-"] ::selection {
|
||||
text-shadow: none;
|
||||
background: #b3d4fc;
|
||||
}
|
||||
|
||||
@media print {
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*="language-"] {
|
||||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:not(pre) > code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
background: #f5f2f0;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre) > code[class*="language-"] {
|
||||
padding: .1em;
|
||||
border-radius: .3em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: slategray;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.token.namespace {
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #905;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #690;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #9a6e3a;
|
||||
/* This background color was intended by the author of this theme. */
|
||||
background: hsla(0, 0%, 100%, .5);
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword {
|
||||
color: #07a;
|
||||
}
|
||||
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: #DD4A68;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important,
|
||||
.token.variable {
|
||||
color: #e90;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user