Compare commits

...

82 Commits

Author SHA1 Message Date
49cd23b21b begin sftp server support 2025-07-23 08:48:42 +02:00
25dfefccb0 Fix redirect
Some checks failed
Build and push image / build-containers (push) Failing after 2m34s
2025-07-18 07:46:41 +02:00
d6357c8a88 Fix broken test 2025-07-18 07:44:36 +02:00
08b85ab3bb Remove old tests 2025-07-18 07:44:30 +02:00
e346949953 Remove logging, add favicon 2025-07-18 07:44:23 +02:00
f312edabd7 Update docker file
Some checks failed
Build and push image / build-containers (push) Has been cancelled
2025-07-18 06:35:47 +02:00
3f6b49a69b Serve frontend from the root 2025-07-18 06:35:36 +02:00
1156bc315e Fix type errors 2025-07-17 20:47:03 +02:00
1362d0a289 Update dependencies 2025-07-17 08:17:13 +02:00
b35a777a89 Add empty frontend target directory 2025-07-17 08:16:59 +02:00
73c7abeb65 Remove static files 2025-07-17 08:16:49 +02:00
d3a0f698ac Fix reference to deleted variable 2025-07-17 07:55:02 +02:00
0059480363 Remove old frontend tests 2025-07-17 07:54:53 +02:00
8beefdf82f Remove old frontend completely 2025-07-17 07:54:37 +02:00
ef8b50e302 Remove old TODO document 2025-07-17 07:47:48 +02:00
33c1e7278b Implement OIDC login 2025-07-16 21:44:20 +02:00
f0c729cba7 Implement validation 2025-07-16 09:22:13 +02:00
f8eac2b09c Handle exceptions better 2025-07-16 09:22:02 +02:00
f518723a0e Implement password change function 2025-07-16 08:51:09 +02:00
37f381c884 Implement password change API endpoint 2025-07-16 08:39:22 +02:00
45ae0929e6 Make audit-table responsive 2025-07-16 06:54:48 +02:00
3efc4d7fa5 Deletions, group moves and validation 2025-07-15 16:53:58 +02:00
412a84150e Stats and error handling 2025-07-15 13:24:50 +02:00
6a5149fd4c Dashboard and error handling 2025-07-15 13:22:11 +02:00
5ac4c987d3 Implement routes and transitions 2025-07-14 12:08:09 +02:00
736dad748b Improve the admin API 2025-07-13 12:04:55 +02:00
746f809d28 Continue frontend building 2025-07-13 12:03:43 +02:00
6faed0dbd4 Standardize IDs, fix group APIs, fix tests 2025-07-07 16:52:28 +02:00
880d556542 Backend fixed and features 2025-07-05 16:01:08 +02:00
3ef659be61 Add new vue-based frontend 2025-07-05 16:00:29 +02:00
c7ecc3f365 Don't create the database from the application.
Use alembic.
2025-06-22 18:57:44 +02:00
82ec7fabb4 Write new secret manager using existing RSA logic 2025-06-22 18:55:01 +02:00
5985a726e3 Fix mobile layout issues 2025-06-21 06:46:28 +02:00
4a5874d4f8 Update views 2025-06-19 19:44:33 +02:00
1cde31a023 Move breadcrumbs to top 2025-06-19 08:56:48 +02:00
4520e9a781 Update css
All checks were successful
Build and push image / build-containers (push) Successful in 8m4s
2025-06-19 08:21:21 +02:00
25879100a4 Merge pull request 'admin-redesign' (#26) from admin-redesign into main
All checks were successful
Build and push image / build-containers (push) Successful in 8m57s
Reviewed-on: #26
2025-06-19 05:24:05 +00:00
2b50c686d0 Add heading back 2025-06-19 07:23:26 +02:00
57e69390b2 Fix dark-mode 2025-06-19 07:21:36 +02:00
23d354bc12 Clean up style elements 2025-06-19 06:43:36 +02:00
cad9849019 Update audit and change-password page 2025-06-19 06:23:19 +02:00
b4c395f0da Update secrets page to new layout 2025-06-19 06:12:36 +02:00
d55c699549 Set breadcrumb dynamically 2025-06-18 19:15:12 +02:00
05775a2e1e Complete inital re-design of client page 2025-06-18 08:54:45 +02:00
9b0588679f fix tabs and tables 2025-06-18 08:34:45 +02:00
d9e0052003 Begin redesign 2025-06-15 10:07:46 +02:00
bf1d119bd8 Use a single button instead of drop down
All checks were successful
Build and push image / build-containers (push) Successful in 6m17s
2025-06-14 22:10:31 +02:00
bce372a1d1 Refactor frontend views
All checks were successful
Build and push image / build-containers (push) Successful in 10m14s
2025-06-14 21:58:21 +02:00
b3debd3ed2 Finalize secret tree page 2025-06-11 19:10:00 +02:00
0eaa913e35 Implement podman-compatible commands
All checks were successful
Build and push image / build-containers (push) Successful in 8m46s
2025-06-10 10:28:17 +02:00
782ec19137 Support unmanaged secrets 2025-06-09 18:04:58 +02:00
43d00cecb4 Preserve history when navigating the secrets page 2025-06-09 15:44:21 +02:00
d1fa6c0076 Implement secret deletion function 2025-06-09 14:15:32 +02:00
71d877022b Implement same ID type as backend API 2025-06-09 14:15:22 +02:00
36d04b8a33 Fix correct secrets API 2025-06-09 14:14:28 +02:00
a834339c13 Fix style 2025-06-09 09:16:12 +02:00
fb6b76f7d8 Fix group object access 2025-06-09 09:16:03 +02:00
fed441743e Keep distinction between Secret and DetailedSecret 2025-06-09 09:15:41 +02:00
d86d9a9256 Adapt admin api to use new key format
Filter out deleted an previous version in count

Remove todo comment

Allow explicit ID specification

Update tests
2025-06-09 08:57:59 +02:00
3779e93b8c Merge pull request 'Refactor backend views, update secret model' (#24) from feature/expanded-secrets into main
All checks were successful
Build and push image / build-containers (push) Successful in 8m3s
Reviewed-on: #24
2025-06-08 15:44:59 +00:00
7ad41f43d8 Refactor backend views 2025-06-08 17:43:34 +02:00
aa6b55a911 Refactor client view 2025-06-06 07:32:51 +02:00
a7a09f7784 Fix linting 2025-06-06 07:32:40 +02:00
ee1e7a16ec Fix typo 2025-06-04 18:12:25 +02:00
435b9dee83 Create Gitea workflow
All checks were successful
Build and push image / build-containers (push) Successful in 9m11s
2025-06-01 17:31:33 +02:00
0eb4e4a34c Update docker 2025-06-01 16:45:42 +02:00
ecad667521 Allow group updates 2025-06-01 16:16:15 +02:00
ba936ac645 Create views for organizing secrets in groups 2025-06-01 15:55:12 +02:00
773a1e2976 Integrate group in admin rest API 2025-05-31 14:13:49 +02:00
18f61631c9 Improve error handling and begin error test suite 2025-05-31 10:55:33 +02:00
289352d872 Add support for groups of secrets 2025-05-31 10:41:58 +02:00
f853ca81d0 Implement password change flow 2025-05-30 16:44:55 +02:00
2585eb1fb3 Pass in user object to templates 2025-05-30 14:41:21 +02:00
391e310b91 Implement oidc login 2025-05-30 10:59:09 +02:00
b491dff4b1 Fix coverage blacklist 2025-05-27 20:15:31 +02:00
e46f6f8d4f Update dependencies 2025-05-19 09:22:23 +02:00
5865cc450f Implement async db access in admin 2025-05-19 09:22:02 +02:00
fc0c3fb950 Refactor to use async database model 2025-05-19 09:15:48 +02:00
f10ae027e5 Add alembic migrations 2025-05-18 22:20:01 +02:00
b8cae28888 Change name of default database 2025-05-18 22:19:49 +02:00
a0adf281b5 Migrate from sqlmodel to pure sqlalchemy 2025-05-18 22:13:07 +02:00
061a52c90a Add more tests 2025-05-18 21:34:46 +02:00
266 changed files with 24775 additions and 10077 deletions

View File

@ -7,7 +7,7 @@ source =
packages/sshecret-sshd/src/sshecret_sshd
omit =
packages/sshecret-backend/src/sshecret_backend/frontend/*
packages/sshecret-admin/src/sshecret_admin/frontend/*
*/__init__.py
*/types.py
*/testing.py

View File

@ -5,3 +5,6 @@
**/__pycache__
.ruff_cache
**/.testing
packages/sshecret-admin/sshecret_admin.db
packages/sshecret-admin/sshecret_admin-key
packages/sshecret-admin/keepass.kdbx

View File

@ -0,0 +1,66 @@
name: Build and push image
on:
push:
branches: [main]
jobs:
build-containers:
runs-on: ubuntu-latest
env:
DOCKER_ORG: eising
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v2
- name: Login to Registry
uses: docker/login-action@v2
with:
registry: git.eising.cloud # replace it with your local IP
username: ${{ gitea.actor }}
password: ${{ secrets.MY_GITEA_TOKEN }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo ${{ gitea.actor }}
echo ${{ gitea.token }}
- name: Build backend and push
uses: docker/build-push-action@v4
with:
context: .
file: ./docker/Dockerfile.backend
platforms: |
linux/amd64
push: true
tags: |
git.eising.cloud/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-backend:${{ gitea.ref == 'refs/heads/main' && 'latest' || gitea.sha }}
- name: Build sshd and push
uses: docker/build-push-action@v4
with:
context: .
file: ./docker/Dockerfile.sshd
platforms: |
linux/amd64
push: true
tags: |
git.eising.cloud/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-sshd:${{ gitea.ref == 'refs/heads/main' && 'latest' || gitea.sha }}
- name: Build admin and push
uses: docker/build-push-action@v4
with:
context: .
file: ./docker/Dockerfile.admin
platforms: |
linux/amd64
push: true
tags: |
git.eising.cloud/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-admin:${{ gitea.ref == 'refs/heads/main' && 'latest' || gitea.sha }}

View File

@ -12,9 +12,20 @@ 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
RUN pip install /opt/sshecret/sshecret-*.whl
RUN pip install /opt/sshecret/sshecret_admin-*.whl
@ -25,6 +36,4 @@ VOLUME /opt/sshecret-admin
WORKDIR /opt/sshecret-admin
ENTRYPOINT [ "sshecret-admin" ]
CMD ["run", "--host", "0.0.0.0"]
CMD ["/entrypoint.sh"]

16
docker/admin.entrypoint.sh Executable file
View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
fail() {
printf '%s\n' "$1" >&2 ## Send message to stderr.
exit "${2-1}" ## Return a code specified by $2, or 1 by default.
}
[[ -d migrations ]] || fail "Error: Must be run from the backend directory."
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
sshecret-admin run --host 0.0.0.0

View File

@ -0,0 +1,119 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
# version_path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
version_path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:///sshecret_admin.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -0,0 +1 @@
Generic single-database configuration.

View File

@ -0,0 +1,105 @@
import os
from logging.config import fileConfig
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.
config = context.config
def get_database_url() -> str | None:
"""Get database 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.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = get_database_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata, render_as_batch=True
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,41 @@
"""Make passwords non-optional
Revision ID: 6c148590471f
Revises: 73d5569a8a26
Create Date: 2025-05-30 10:15:03.665371
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "6c148590471f"
down_revision: Union[str, None] = "73d5569a8a26"
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! ###
with op.batch_alter_table("user", schema=None) as batch_op:
batch_op.alter_column(
"hashed_password", existing_type=sa.VARCHAR(), nullable=True
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("user", schema=None) as batch_op:
batch_op.alter_column(
"hashed_password", existing_type=sa.VARCHAR(), nullable=False
)
# ### end Alembic commands ###

View File

@ -0,0 +1,82 @@
"""Create initial migration
Revision ID: 73d5569a8a26
Revises:
Create Date: 2025-05-30 10:02:05.130137
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "73d5569a8a26"
down_revision: Union[str, None] = None
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(
"password_db",
sa.Column("id", sa.INTEGER(), nullable=False),
sa.Column("encrypted_password", sa.String(), nullable=False),
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.PrimaryKeyConstraint("id"),
)
op.create_table(
"user",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("email", sa.String(), nullable=False),
sa.Column("full_name", sa.String(), nullable=True),
sa.Column("disabled", sa.BOOLEAN(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column("username", sa.String(), nullable=True),
sa.Column("hashed_password", sa.String(), nullable=False),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=True,
),
sa.Column("oidc_sub", sa.String(), nullable=True),
sa.Column("oidc_issuer", sa.String(), nullable=True),
sa.Column(
"provider", sa.Enum("LOCAL", "OIDC", name="authprovider"), nullable=False
),
sa.Column("last_login", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email", name="uq_user_email"),
sa.UniqueConstraint("oidc_sub", name="uq_user_oidc_sub"),
sa.UniqueConstraint("username", name="uq_user_username"),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("user")
op.drop_table("password_db")
# ### end Alembic commands ###

View File

@ -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 ###

View File

@ -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 ###

View File

@ -8,13 +8,17 @@ authors = [
]
requires-python = ">=3.13"
dependencies = [
"alembic>=1.15.2",
"authlib>=1.6.0",
"bcrypt>=4.3.0",
"click>=8.1.8",
"cryptography>=44.0.2",
"fastapi[standard]>=0.115.12",
"httpx>=0.28.1",
"itsdangerous>=2.2.0",
"jinja2>=3.1.6",
"jinja2-fragments>=1.9.0",
"joserfc>=1.1.0",
"pydantic>=2.10.6",
"pyjwt>=2.10.1",
"pykeepass>=4.1.1.post1",

View File

@ -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

View File

@ -1,39 +1,227 @@
"""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 sqlmodel 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
)
callback_url = f"/auth_cb#access_token={access_token}&refresh_token={refresh_token}"
if dependencies.settings.frontend_test_url:
callback_url = os.path.join(dependencies.settings.frontend_test_url, callback_url)
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

View File

@ -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."""
@ -25,12 +55,29 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
@app.get("/clients/")
async def get_clients(
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)]
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> list[Client]:
"""Get clients."""
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

View File

@ -3,28 +3,38 @@
# pyright: reportUnusedFunction=false
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sshecret.backend.models import Secret
from sshecret_admin.core.dependencies import AdminDependencies
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__)
def create_router(dependencies: AdminDependencies) -> APIRouter:
"""Create secrets router."""
app = APIRouter(dependencies=[Depends(dependencies.get_current_active_user)])
app = APIRouter(dependencies=[Security(dependencies.get_current_active_user)])
@app.get("/secrets/")
async def get_secret_names(
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)]
) -> list[Secret]:
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> list[SecretListView]:
"""Get Secret Names."""
return await admin.get_secrets()
@ -34,7 +44,13 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None:
"""Create a secret."""
await admin.add_secret(secret.name, secret.get_secret(), secret.clients)
await admin.add_secret(
name=secret.name,
value=secret.get_secret(),
clients=secret.clients,
group=secret.group,
distinguisher=secret.client_distinguisher,
)
@app.get("/secrets/{name}")
async def get_secret(
@ -67,4 +83,149 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
"""Delete secret."""
await admin.delete_secret(name)
@app.get("/secrets/groups/")
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."""
result = await admin.get_secret_groups(filter_regex, flat=flat)
return result
@app.get("/secrets/groups/{group_path:path}/")
async def get_secret_group(
group_path: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> ClientSecretGroup:
"""Get a specific secret group."""
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
@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(
group: SecretGroupCreate,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> ClientSecretGroup:
"""Create a secret grouping."""
await admin.add_secret_group(
group_name=group.name,
description=group.description,
parent_group=group.parent_group,
)
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/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_path: str,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None:
"""Remove a group.
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_by_path(group_path)
if not group:
return
await admin.delete_secret_group(group_path)
@app.post("/secrets/set-group")
async def assign_secret_group(
assignment: SecretGroupAssign,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None:
"""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="Secret not fount"
)
except InvalidGroupNameError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Invalid group name"
)
@app.post("/secrets/move-group/{group_name:path}")
async def move_group(
group_name: str,
destination: GroupPath,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> None:
"""Move a group."""
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_path: str | None = destination.path
if destination.path == "/" or not destination.path:
# / means root
parent_path = None
LOG.debug("Moving group %s to %r", group_name, parent_path)
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

View File

@ -5,16 +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 sqlmodel import Session, select
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__)
@ -24,7 +27,7 @@ API_VERSION = "v1"
def create_router(dependencies: BaseDependencies) -> APIRouter:
"""Create clients router."""
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token", refreshUrl="/api/v1/refresh")
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
@ -40,13 +43,46 @@ def create_router(dependencies: BaseDependencies) -> APIRouter:
if not token_data:
raise credentials_exception
user = session.exec(
select(User).where(User.username == token_data.username)
if token_data.provider == LOCAL_ISSUER:
user = session.scalars(
select(User).where(User.username == token_data.sub)
).first()
else:
user = session.scalars(
select(User)
.where(User.oidc_issuer == token_data.provider)
.where(User.oidc_sub == token_data.sub)
).first()
if not user:
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:
@ -56,15 +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.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
if not password_db:
raise HTTPException(
500, detail="Error: The password manager has not yet been set up."
username = get_optional_username(request)
origin = get_client_origin(request)
admin = AdminBackend(
dependencies.settings,
username=username,
origin=origin,
)
admin = AdminBackend(dependencies.settings, password_db.encrypted_password)
yield admin
app = APIRouter(prefix=f"/api/{API_VERSION}")
@ -75,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))

View File

@ -2,20 +2,24 @@
from .authentication import (
authenticate_user,
authenticate_user_async,
create_access_token,
create_refresh_token,
check_password,
decode_token,
verify_password,
)
from .models import User, Token, PasswordDB
from .models import User, Token, PasswordDB, IdentityClaims, LocalUserInfo
__all__ = [
"IdentityClaims",
"LocalUserInfo",
"PasswordDB",
"Token",
"User",
"authenticate_user",
"authenticate_user_async",
"check_password",
"create_access_token",
"create_refresh_token",

View File

@ -5,18 +5,26 @@ from datetime import datetime, timezone, timedelta
from typing import cast, Any
import bcrypt
import jwt
from sqlmodel import Session, select
from joserfc import jwt
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from joserfc.jwk import OctKey
from joserfc.errors import JoseError
from sshecret_admin.core.settings import AdminServerSettings
from .models import User, TokenData
from .exceptions import AuthenticationFailedError
JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
# I know refresh tokens are supposed to be long-lived, but 6 hours for a
# sensitive application, seems reasonable.
REFRESH_TOKEN_EXPIRE_HOURS = 6
from .models import AuthProvider, LocalUserInfo, User, IdentityClaims
from .exceptions import AuthenticationFailedError
from .constants import (
JWT_ALGORITHM,
ACCESS_TOKEN_EXPIRE_MINUTES,
REFRESH_TOKEN_EXPIRE_HOURS,
LOCAL_ISSUER,
)
LOG = logging.getLogger(__name__)
@ -25,12 +33,14 @@ def create_token(
settings: AdminServerSettings,
data: dict[str, Any],
expires_delta: timedelta,
provider: str,
) -> str:
"""Create access token."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + expires_delta
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=JWT_ALGORITHM)
to_encode.update({"exp": expire, "iss": provider})
key = OctKey.import_key(settings.secret_key)
encoded_jwt = jwt.encode({"alg": JWT_ALGORITHM}, to_encode, key)
return str(encoded_jwt)
@ -38,22 +48,24 @@ def create_access_token(
settings: AdminServerSettings,
data: dict[str, Any],
expires_delta: timedelta | None = None,
provider: str = LOCAL_ISSUER,
) -> str:
"""Create access token."""
if not expires_delta:
expires_delta = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return create_token(settings, data, expires_delta)
return create_token(settings, data, expires_delta, provider)
def create_refresh_token(
settings: AdminServerSettings,
data: dict[str, Any],
expires_delta: timedelta | None = None,
provider: str = LOCAL_ISSUER,
) -> str:
"""Create access token."""
if not expires_delta:
expires_delta = timedelta(hours=REFRESH_TOKEN_EXPIRE_HOURS)
return create_token(settings, data, expires_delta)
return create_token(settings, data, expires_delta, provider)
def verify_password(plain_password: str, hashed_password: str) -> bool:
@ -70,9 +82,13 @@ def check_password(plain_password: str, hashed_password: str) -> None:
raise AuthenticationFailedError()
def authenticate_user(session: Session, username: str, password: str) -> User | None:
"""Authenticate user."""
user = session.exec(select(User).where(User.username == username)).first()
async def authenticate_user_async(
session: AsyncSession, username: str, password: str
) -> User | None:
"""Authenticate user async."""
user = (
await session.scalars(select(User).where(User.username == username))
).first()
if not user:
return None
if not verify_password(password, user.hashed_password):
@ -80,22 +96,96 @@ def authenticate_user(session: Session, username: str, password: str) -> User |
return user
def decode_token(settings: AdminServerSettings, token: str) -> TokenData | None:
async def handle_oidc_claim(session: AsyncSession, claim: IdentityClaims) -> User:
"""Handle OIDC claim.
Either return an existing user, or create a new one.
"""
LOG.debug("Looking up OIDC token claim %r", claim)
if claim.provider == LOCAL_ISSUER:
raise ValueError("IdentityClaims do not originate from OIDC.")
query = (
select(User)
.where(User.oidc_sub == claim.sub)
.where(User.oidc_issuer == claim.provider)
)
result = await session.execute(query)
if user := result.scalar_one_or_none():
LOG.debug("Found existing user %s", user.id)
return user
LOG.debug("User not found in local database. Creating a new user")
user = User(
username=claim.username,
email=claim.email,
disabled=False,
oidc_sub=claim.sub,
oidc_issuer=claim.provider,
provider=AuthProvider.OIDC,
)
session.add(user)
await session.commit()
query = (
select(User)
.where(User.oidc_sub == claim.sub)
.where(User.oidc_issuer == claim.provider)
)
result = await session.execute(query)
return result.scalar_one()
def authenticate_user(session: Session, username: str, password: str) -> User | None:
"""Authenticate user."""
user = session.scalars(select(User).where(User.username == username)).first()
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def decode_token(settings: AdminServerSettings, token: str) -> IdentityClaims | None:
"""Decode token."""
key = OctKey.import_key(settings.secret_key)
try:
decoded = jwt.decode(token, key)
claims_requests = jwt.JWTClaimsRegistry(
exp={"essential": True},
sub={"essential": True},
)
claims_requests.validate(decoded.claims)
payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM])
username = cast("str | None", payload.get("sub"))
if not username:
sub = cast("str | None", payload.claims.get("sub"))
if not sub:
return None
token_data = TokenData(username=username)
return token_data
except jwt.InvalidTokenError as e:
issuer = payload.claims.get("iss") or LOCAL_ISSUER
identity_claims = IdentityClaims(sub=sub, provider=issuer)
if issuer == LOCAL_ISSUER:
identity_claims.username = sub
return identity_claims
except JoseError as e:
LOG.debug("Could not decode token: %s", e, exc_info=True)
return None
def hash_password(password: str) -> str:
"""Hash password."""
salt = bcrypt.gensalt()
hashed_password = bcrypt.hashpw(password.encode(), salt)
return hashed_password.decode()
def generate_user_info(user: User) -> LocalUserInfo:
"""Generate user info object from a user entry."""
is_local = user.provider == AuthProvider.LOCAL
if user.username:
LOG.info("User has a username: %s", user.username)
return LocalUserInfo(id=user.id, display_name=user.username, local=is_local)
assert user.email is not None
LOG.info("User has no username")
return LocalUserInfo(id=user.id, display_name=user.email, local=is_local)

View File

@ -0,0 +1,8 @@
"""Constants."""
JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
# I know refresh tokens are supposed to be long-lived, but 6 hours for a
# sensitive application, seems reasonable.
REFRESH_TOKEN_EXPIRE_HOURS = 6
LOCAL_ISSUER = "urn:sshecret:admin:auth"

View File

@ -1,4 +1,5 @@
"""Authentication related exceptions."""
from typing import override
from .models import LoginError

View File

@ -1,8 +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 sqlmodel import SQLModel, Field
from pydantic import BaseModel
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
JWT_ALGORITHM = "HS256"
@ -12,59 +17,180 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_HOURS = 6
class User(SQLModel, table=True):
class AuthProvider(enum.Enum):
"""Auth providers."""
LOCAL = "local"
OIDC = "oidc"
class Base(DeclarativeBase):
pass
class User(Base):
"""Users."""
username: str = Field(unique=True, primary_key=True)
hashed_password: str
disabled: bool = Field(default=False)
created_at: datetime | None = Field(
default=None,
sa_type=sa.DateTime(timezone=True),
sa_column_kwargs={"server_default": sa.func.now()},
nullable=False,
__tablename__: str = "user"
__table_args__: tuple[sa.UniqueConstraint, ...] = (
sa.UniqueConstraint("username", name="uq_user_username"),
sa.UniqueConstraint("email", name="uq_user_email"),
sa.UniqueConstraint("oidc_sub", name="uq_user_oidc_sub"),
)
id: Mapped[uuid.UUID] = mapped_column(
sa.Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4
)
email: Mapped[str] = mapped_column(sa.String, nullable=False)
full_name: Mapped[str] = mapped_column(sa.String, nullable=True)
disabled: Mapped[bool] = mapped_column(sa.BOOLEAN, default=False)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
)
username: Mapped[str] = mapped_column(sa.String, nullable=True)
hashed_password: Mapped[str] = mapped_column(sa.String, nullable=True)
updated_at: Mapped[datetime | None] = mapped_column(
sa.DateTime(timezone=True),
server_default=sa.func.now(),
onupdate=sa.func.now(),
)
oidc_sub: Mapped[str] = mapped_column(sa.String, nullable=True)
oidc_issuer: Mapped[str] = mapped_column(sa.String, nullable=True)
provider: Mapped[AuthProvider] = mapped_column(
sa.Enum(AuthProvider), nullable=False
)
last_login: Mapped[datetime | None] = mapped_column(
sa.DateTime(timezone=True), nullable=True
)
class PasswordDB(SQLModel, table=True):
class PasswordDB(Base):
"""Password database."""
id: int | None = Field(default=None, primary_key=True)
encrypted_password: str
__tablename__: str = "password_db"
created_at: datetime | None = Field(
default=None,
sa_type=sa.DateTime(timezone=True),
sa_column_kwargs={"server_default": sa.func.now()},
nullable=False,
id: Mapped[int] = mapped_column(sa.INT, primary_key=True)
created_at: Mapped[datetime] = mapped_column(
sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
)
updated_at: datetime | None = Field(
default=None,
sa_type=sa.DateTime(timezone=True),
sa_column_kwargs={"onupdate": sa.func.now(), "server_default": sa.func.now()},
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(),
onupdate=sa.func.now(),
)
class Group(Base):
"""A secret group."""
class TokenData(SQLModel):
__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."""
sub: str
email: str | None = None
username: str | None = None
provider: str
class TokenData(BaseModel):
"""Token data."""
username: str | None = None
class Token(SQLModel):
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str
class LoginError(SQLModel):
class LoginError(BaseModel):
"""Login Error model."""
# TODO: Remove this.
title: str
message: str
def init_db(engine: sa.Engine) -> None:
"""Create database."""
SQLModel.metadata.create_all(engine)
class LocalUserInfo(BaseModel):
"""Model used to present a user in the web ui."""
id: uuid.UUID
display_name: str
local: bool
class LoginInfo(BaseModel):
"""Model containing information about login providers."""
enabled: bool
oidc_provider: str | None = None

View File

@ -0,0 +1,78 @@
"""OIDC Handler class."""
import logging
from collections.abc import Awaitable
from typing import cast
from authlib.integrations.starlette_client import OAuth, OAuthError
from authlib.integrations.starlette_client.apps import StarletteOAuth2App
from fastapi import Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sshecret_admin.auth.exceptions import AuthenticationFailedError
from sshecret_admin.auth.models import IdentityClaims
from sshecret_admin.core.settings import OidcSettings
from starlette.datastructures import URL
class OIDCUserInfo(BaseModel):
sub: str
email: str | None
preferred_username: str | None = None
name: str | None = None
picture: str | None = None
LOG = logging.getLogger(__name__)
class AdminOidc:
"""Admin OIDC handler."""
def __init__(self, settings: OidcSettings) -> None:
"""Initialize OIDC handler class."""
self.settings: OidcSettings = settings
self.provider_name: str = settings.name
self.oauth: OAuth = OAuth()
self.oauth.register(
name=settings.name,
server_metadata_url=settings.config_url,
client_id=settings.client_id,
client_secret=settings.client_secret,
client_kwargs={"scope": "openid email profile"},
)
@property
def client(self) -> StarletteOAuth2App:
"""Get client."""
app = cast(
StarletteOAuth2App | None, self.oauth.create_client(self.provider_name)
)
if app is None:
raise RuntimeError("Unexpected error when creating Oauth2 client.")
return app
async def start_auth(self, request: Request, redirect_url: URL) -> RedirectResponse:
"""Start authentication flow."""
response = cast(
Awaitable[RedirectResponse],
self.client.authorize_redirect(request, redirect_url),
)
return await response
async def handle_auth_callback(self, request: Request) -> IdentityClaims:
"""Handle auth callback."""
try:
token = await self.client.authorize_access_token(request)
except OAuthError as error:
LOG.error("Error from OIDC: %s", error, exc_info=True)
raise AuthenticationFailedError(str(error))
LOG.info("Token: %r", token)
claims = await self.client.parse_id_token(token, None)
user_info = OIDCUserInfo.model_validate(claims)
return IdentityClaims(
sub=user_info.sub,
email=user_info.email,
provider=self.provider_name,
username=user_info.preferred_username,
)

View File

@ -2,79 +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 sqlmodel import Session, select
from sshecret_admin import api, frontend
from sshecret_admin.auth.models import PasswordDB, init_db
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
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.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
if not encr_master_password:
if existing_password:
LOG.info("Master password already defined.")
return
# Looks like we have to regenerate it
LOG.warning("Master password was set, but not saved to the database. Regenerating it.")
encr_master_password = setup_master_password(settings=settings, regenerate=True)
assert encr_master_password is not None
with Session(engine) as session:
pwdb = PasswordDB(id=1, encrypted_password=encr_master_password)
session.add(pwdb)
session.commit()
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(
@ -85,15 +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:
@ -102,11 +113,26 @@ 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."""
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.."""
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

View File

@ -1,19 +1,19 @@
"""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
from pydantic import ValidationError
from sqlmodel import Session, create_engine, select
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 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(
@ -27,10 +27,15 @@ LOG.addHandler(handler)
LOG.setLevel(logging.INFO)
def create_user(session: Session, username: str, password: str) -> None:
def create_user(session: Session, username: str, email: str, password: str) -> None:
"""Create a user."""
hashed_password = hash_password(password)
user = User(username=username, hashed_password=hashed_password)
user = User(
username=username,
email=email,
hashed_password=hashed_password,
provider=AuthProvider.LOCAL,
)
session.add(user)
session.commit()
@ -57,15 +62,17 @@ def cli(ctx: click.Context, debug: bool) -> None:
@cli.command("adduser")
@click.argument("username")
@click.argument("email")
@click.password_option()
@click.pass_context
def cli_create_user(ctx: click.Context, username: str, password: str) -> None:
def cli_create_user(
ctx: click.Context, username: str, email: str, password: str
) -> None:
"""Create user."""
settings = cast(AdminServerSettings, ctx.obj)
engine = create_engine(settings.admin_db)
init_db(engine)
with Session(engine) as session:
create_user(session, username, password)
create_user(session, username, email, password)
click.echo("User created.")
@ -78,9 +85,8 @@ 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.exec(select(User).where(User.username == username)).first()
user = session.scalars(select(User).where(User.username == username)).first()
if not user:
raise click.ClickException(f"Error: No such user, {username}.")
new_passwd_hash = hash_password(password)
@ -98,9 +104,8 @@ def cli_delete_user(ctx: click.Context, username: str) -> None:
"""Remove a user."""
settings = cast(AdminServerSettings, ctx.obj)
engine = create_engine(settings.admin_db)
init_db(engine)
with Session(engine) as session:
user = session.exec(select(User).where(User.username == username)).first()
user = session.scalars(select(User).where(User.username == username)).first()
if not user:
raise click.ClickException(f"Error: No such user, {username}.")
@ -134,31 +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.exec(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()}")

View File

@ -1,18 +1,38 @@
"""Database setup."""
from collections.abc import Generator, Callable
import sqlite3
from contextlib import asynccontextmanager
from sqlmodel import Session, create_engine
import sqlalchemy as sa
from collections.abc import AsyncIterator, Generator, Callable
from sqlalchemy.orm import Session
from sqlalchemy.engine import URL
from sqlalchemy import create_engine, Engine, event
from sqlalchemy.ext.asyncio import (
AsyncConnection,
create_async_engine,
AsyncEngine,
AsyncSession,
async_sessionmaker,
)
def setup_database(
db_url: URL | str,
) -> tuple[sa.Engine, Callable[[], Generator[Session, None, None]]]:
db_url: URL,
) -> tuple[Engine, Callable[[], Generator[Session, None, None]]]:
"""Setup database."""
engine = create_engine(db_url, echo=False)
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."""
@ -20,3 +40,57 @@ def setup_database(
yield session
return engine, get_db_session
class DatabaseSessionManager:
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
)
)
async def close(self):
if self._engine is None:
raise Exception("DatabaseSessionManager is not initialized")
await self._engine.dispose()
self._engine = None
self._sessionmaker = None
@asynccontextmanager
async def connect(self) -> AsyncIterator[AsyncConnection]:
if self._engine is None:
raise Exception("DatabaseSessionManager is not initialized")
async with self._engine.begin() as connection:
try:
yield connection
except Exception:
await connection.rollback()
raise
@asynccontextmanager
async def session(self) -> AsyncIterator[AsyncSession]:
if self._sessionmaker is None:
raise Exception("DatabaseSessionManager is not initialized")
session = self._sessionmaker()
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()

View File

@ -1,18 +1,21 @@
"""Common type definitions."""
from collections.abc import AsyncGenerator, Callable, Generator
from collections.abc import AsyncGenerator, Awaitable, Callable, Generator
from dataclasses import dataclass
from typing import Awaitable, Self
from typing import Self
from sqlmodel import Session
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
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,
)

View File

@ -1,4 +1,5 @@
"""Main server app."""
import sys
import click
from pydantic import ValidationError

View File

@ -1,21 +1,33 @@
"""SSH Server settings."""
from pathlib import Path
from pydantic import AnyHttpUrl, Field
from pydantic import AnyHttpUrl, BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import URL
DEFAULT_LISTEN_PORT = 8822
DEFAULT_DATABASE = "ssh_admin.db"
DEFAULT_DATABASE = "sshecret_admin.db"
class OidcSettings(BaseModel):
"""OIDC settings."""
name: str
config_url: str
client_id: str
client_secret: str
class AdminServerSettings(BaseSettings):
"""Server Settings."""
model_config = SettingsConfigDict(
env_file=".admin.env", env_prefix="sshecret_admin_", secrets_dir="/var/run"
env_file=".admin.env",
env_prefix="sshecret_admin_",
secrets_dir="/var/run",
env_nested_delimiter="__",
)
backend_url: AnyHttpUrl = Field(alias="sshecret_backend_url")
@ -26,8 +38,17 @@ class AdminServerSettings(BaseSettings):
database: str = Field(default=DEFAULT_DATABASE)
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:
"""Construct database url."""
return URL.create(drivername="sqlite", database=self.database)
@property
def async_db_url(self) -> URL:
"""Construct database url with sync handling."""
return URL.create(drivername="sqlite+aiosqlite", database=self.database)

View File

@ -1,5 +0,0 @@
"""Frontend app."""
from .router import create_router as create_frontend_router
__all__ = ["create_frontend_router"]

View File

@ -1,7 +0,0 @@
"""Custom oauth2 class."""
from fastapi.security import OAuth2
class Oauth2TokenInCookies(OAuth2):
"""TODO: Create this."""

View File

@ -1,48 +0,0 @@
"""Frontend dependencies."""
from dataclasses import dataclass
from collections.abc import Callable, Awaitable
from typing import Self
from jinja2_fragments.fastapi import Jinja2Blocks
from fastapi import Request
from sqlmodel import Session
from sshecret_admin.core.dependencies import AdminDep, BaseDependencies
from sshecret_admin.auth.models import User
UserTokenDep = Callable[[Request, Session], Awaitable[User]]
UserLoginDep = Callable[[Request, Session], Awaitable[bool]]
@dataclass
class FrontendDependencies(BaseDependencies):
"""Frontend dependencies."""
get_admin_backend: AdminDep
templates: Jinja2Blocks
get_user_from_access_token: UserTokenDep
get_user_from_refresh_token: UserTokenDep
get_login_status: UserLoginDep
@classmethod
def create(
cls,
deps: BaseDependencies,
get_admin_backend: AdminDep,
templates: Jinja2Blocks,
get_user_from_access_token: UserTokenDep,
get_user_from_refresh_token: UserTokenDep,
get_login_status: UserLoginDep,
) -> 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_user_from_access_token=get_user_from_access_token,
get_user_from_refresh_token=get_user_from_refresh_token,
get_login_status=get_login_status,
)

View File

@ -1,13 +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

View File

@ -1,133 +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 sqlmodel import Session, select
from starlette.datastructures import URL
from sshecret_admin.auth import PasswordDB, User, decode_token
from sshecret_admin.core.dependencies import BaseDependencies
from sshecret_admin.services.admin_backend import AdminBackend
from .dependencies import FrontendDependencies
from .exceptions import RedirectException
from .views import audit, auth, clients, index, secrets
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.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
if not password_db:
raise HTTPException(
500, detail="Error: The password manager has not yet been set up."
)
admin = AdminBackend(dependencies.settings, password_db.encrypted_password)
yield admin
async def get_user_from_token(
token: str,
session: Session,
) -> User | None:
"""Get user from a token."""
token_data = decode_token(dependencies.settings, token)
if not token_data:
return None
user = session.exec(
select(User).where(User.username == token_data.username)
).first()
if not user or user.disabled:
return None
return user
async def get_user_from_refresh_token(
request: Request,
session: Annotated[Session, Depends(dependencies.get_db_session)],
) -> User:
"""Get user from refresh token."""
next = URL("/login").include_query_params(next=request.url.path)
credentials_error = RedirectException(to=next)
token = request.cookies.get("refresh_token")
if not token:
raise credentials_error
user = await get_user_from_token(token, session)
if not user:
raise credentials_error
return user
async def get_user_from_access_token(
request: Request,
session: Annotated[Session, Depends(dependencies.get_db_session)],
) -> User:
"""Get user from access token."""
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
user = await get_user_from_token(token, session)
if not user:
raise credentials_error
return user
async def get_login_status(
request: Request,
session: Annotated[Session, Depends(dependencies.get_db_session)],
) -> bool:
"""Get login status."""
token = request.cookies.get("access_token")
if not token:
return False
user = await get_user_from_token(token, session)
if not user:
return False
return True
view_dependencies = FrontendDependencies.create(
dependencies,
get_admin_backend,
templates,
get_user_from_access_token,
get_user_from_refresh_token,
get_login_status,
)
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))
return app

View File

@ -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>

View File

@ -1,61 +0,0 @@
{% extends "/dashboard/_base.html" %} {% block content %}
<div
class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700"
>
<div class="w-full mb-1">
<div class="mb-4">
<nav class="flex mb-5" aria-label="Breadcrumb">
<ol
class="inline-flex items-center space-x-1 text-sm font-medium md:space-x-2"
>
<li class="inline-flex items-center">
<a
href="/"
class="inline-flex items-center text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-white"
>
<svg
class="w-5 h-5 mr-2.5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"
></path>
</svg>
Home
</a>
</li>
<li>
<div class="flex items-center">
<svg
class="w-6 h-6 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 8h6m-6 4h6m-6 4h6M6 3v18l2-2 2 2 2-2 2 2 2-2 2 2V3l-2 2-2-2-2 2-2-2-2 2-2-2Z"
/>
<span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Audit Log</span>
</svg>
</div>
</li>
</ol>
</nav>
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Audit Log</h1>
</div>
</div>
</div>
<div id="auditContent">
{% include 'audit/inner.html.j2' %}
</div>
{% endblock %}

View File

@ -1,183 +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 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>

View File

@ -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>

View File

@ -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>

View File

@ -1,82 +0,0 @@
<tr
class="hover:bg-gray-100 dark:hover:bg-gray-700"
id="client-{{ client.id }}"
>
<td
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
>
{{-client.name -}}
</td>
<td
class="p-4 text-base font-medium text-gray-900 whitespace-nowrap dark:text-white"
>
{{- client.id -}}
</td>
<td
class="max-w-sm p-4 overflow-hidden text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400"
>
{{- client.description -}}
</td>
<td
class="max-w-sm p-4 text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400"
>
{{- client.secrets|length -}}
</td>
<td
class="max-w-sm p-4 text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400"
>
{{- client.policies|join(', ') -}}
</td>
<td class="p-4 space-x-2 whitespace-nowrap">
<button
type="button"
id="updateClientButton-{{ client.id }}"
data-drawer-target="drawer-update-client-{{ client.id }}"
data-drawer-show="drawer-update-client-{{ client.id }}"
aria-controls="drawer-update-client-{{ client.id }}"
data-drawer-placement="right"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
<svg
class="w-4 h-4 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"
></path>
<path
fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"
></path>
</svg>
Update
</button>
<button
type="button"
id="deleteClientButton-{{ client.id }}"
data-drawer-target="drawer-delete-client-{{ client.id }}"
data-drawer-show="drawer-delete-client-{{ client.id }}"
aria-controls="drawer-delete-client-{{ client.id }}"
data-drawer-placement="right"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-red-700 rounded-lg hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900"
>
<svg
class="w-4 h-4 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
></path>
</svg>
Delete item
</button>
</td>
</tr>

View File

@ -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="#clientContent">
{% include '/clients/drawer_client_create_inner.html.j2' %}
</form>
</div>

View File

@ -1,108 +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="Client name"
required=""
/>
</div>
<div>
<label
for="description"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Description</label
>
<input
type="text"
name="description"
id="description"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Client description"
/>
</div>
<div>
<label
for="sources"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Allowed subnets or IPs</label
>
<p
id="helper-text-explanation"
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
>
Separate multiple entries with comma.
</p>
<input
type="text"
name="sources"
id="sources"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="0.0.0.0/0"
value="0.0.0.0/0"
hx-post="/clients/validate/source"
hx-target="#clientSourceValidation"
/>
<span id="clientSourceValidation"></span>
</div>
<div>
<label
for="public_key"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Public Key</label
>
<textarea
id="public_key"
name="public_key"
rows="4"
class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Enter RSA SSH Public Key here"
hx-post="/clients/validate/public_key"
hx-target="#clientPublicKeyValidation"
></textarea>
<span id="clientPublicKeyValidation"></span>
</div>
<div
class="bottom-0 left-0 flex justify-center w-full pb-4 space-x-4 md:px-4 md:absolute"
>
<button
type="submit"
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
Add Client
</button>
<button
type="button"
data-drawer-dismiss="drawer-create-client-default"
aria-controls="drawer-create-client-default"
class="inline-flex w-full justify-center text-gray-500 items-center bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-primary-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"
>
<svg
aria-hidden="true"
class="w-5 h-5 -ml-1 sm:mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
Cancel
</button>
</div>
</div>

View File

@ -1,67 +0,0 @@
<div
id="drawer-delete-client-{{ client.id }}"
class="fixed top-0 right-0 z-40 w-full h-screen max-w-xs p-4 overflow-y-auto transition-transform translate-x-full bg-white dark:bg-gray-800"
tabindex="-1"
aria-labelledby="drawer-label"
aria-hidden="true"
>
<h5
id="drawer-label"
class="inline-flex items-center text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
>
Delete Client {{client.name}}
</h5>
<button
type="button"
data-drawer-dismiss="drawer-delete-client-{{ client.id }}"
aria-controls="drawer-delete-client-{{ client.id }}"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg
aria-hidden="true"
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
<span class="sr-only">Close menu</span>
</button>
<svg
class="w-10 h-10 mt-8 mb-4 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<h3 class="mb-6 text-lg text-gray-500 dark:text-gray-400">
Are you sure you want to delete this client?
</h3>
<button
type="button"
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm inline-flex items-center px-3 py-2.5 text-center mr-2 dark:focus:ring-red-900"
hx-delete="/clients/{{ client.id }}"
hx-target="#clientContent"
>
Yes, delete the client
</button>
<a
href="#"
class="text-gray-900 bg-white hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 border border-gray-200 font-medium inline-flex items-center rounded-lg text-sm px-3 py-2.5 text-center dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-gray-700"
data-drawer-hide="drawer-delete-client-{{ client.id }}"
>
No, cancel
</a>
</div>

View File

@ -1,174 +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 mb-6 text-sm font-semibold text-gray-500 uppercase dark:text-gray-400"
>
<div role="status" class="mr-2 htmx-indicator" id="spinner-{{ client.id}}">
<svg aria-hidden="true" class="inline w-4 h-4 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
</svg>
<span class="sr-only">Loading...</span>
</div>
Update Client
</h5>
<button
type="button"
data-drawer-dismiss="drawer-update-client-{{ client.id }}"
aria-controls="drawer-update-client-{{ client.id }}"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg
aria-hidden="true"
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
<span class="sr-only">Close menu</span>
</button>
<form
hx-put="/clients/{{ client.id }}"
hx-target="#clientContent"
hx-indicator="spinner-{{ client.id }}"
>
<input type="hidden" name="id" value="{{ client.id }}" />
<div class="space-y-4">
<div>
<label
for="name"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Name</label
>
<input
type="text"
name="name"
id="name-{{ client.id }}"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Client name"
value="{{ client.name }}"
required=""
/>
</div>
<div>
<label
for="description"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Description</label
>
<input
type="text"
name="description"
id="description-{{ client.id }}"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Client description"
value="{{ client.description}}"
/>
</div>
<div>
<label
for="sources"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Allowed subnets or IPs</label
>
<p
id="helper-text-explanation-{{ client.id }}"
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
>
Separate multiple entries with comma.
</p>
<input
type="text"
name="sources"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="0.0.0.0/0"
id="sources-{{client.id}}"
hx-post="/clients/validate/source"
hx-target="#clientSourceValidation-{{ client.id }}"
value="{{ client.policies|join(", ") }}"
/>
<span id="clientSourceValidation-{{ client.id }}"></span>
</div>
<div>
<label
for="public_key"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Public Key</label
>
<p
id="helper-text-explanation-{{ client.id }}"
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
>
Note that updating the key will invalidate all secrets associated with
this client.
</p>
<textarea
id="public_key-{{ client.id }}"
name="public_key"
rows="14"
class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
placeholder="Enter RSA SSH Public Key here"
hx-post="/clients/validate/public_key"
hx-indicator="spinner-{{ client.id }}"
hx-target="#clientPublicKeyValidation-{{ client.id }}"
>
{{- client.public_key -}}</textarea
>
<span id="clientPublicKeyValidation-{{ client.id }}"></span>
</div>
</div>
<div>
<div
class="bottom-0 left-0 flex justify-center w-full pb-4 mt-4 space-x-4 sm:absolute sm:px-4 sm:mt-0"
>
<button
type="submit"
class="w-full justify-center text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
Update
</button>
<button
type="button"
class="w-full justify-center text-red-600 inline-flex items-center hover:text-white border border-red-600 hover:bg-red-600 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:border-red-500 dark:text-red-500 dark:hover:text-white dark:hover:bg-red-600 dark:focus:ring-red-900"
hx-delete="/clients/{{ client.id }}"
hx-confirm="Are you sure?"
hx-target="#clientContent"
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>

View File

@ -1,3 +0,0 @@
<template>
{% include '/clients/inner.html.j2' %}
</template>

View File

@ -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>

View File

@ -1,45 +0,0 @@
{% extends "/dashboard/_base.html" %} {% block content %}
<div class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700">
<div class="w-full mb-1">
<div class="mb-4">
<nav class="flex mb-5" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 text-sm font-medium md:space-x-2">
<li class="inline-flex items-center">
<a href="/" class="inline-flex items-center text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-white">
<svg class="w-5 h-5 mr-2.5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg>
Home
</a>
</li>
<li>
<div class="flex items-center">
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
<span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Clients</span>
</svg>
</div>
</li>
</ol>
</nav>
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Clients</h1>
</div>
<div class="items-center justify-between block sm:flex">
<div class="flex items-center mb-4 sm:mb-0">
<label for="client-search" class="sr-only">Search</label>
<div class="relative w-48 mt-1 sm:w-64 xl:w-96">
<input type="search" name="query" id="client-search" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" placeholder="Search for clients" hx-post="/clients/query" hx-trigger="keyup changed delay:500ms, query" hx-target="#clientContent">
</div>
</div>
<button id="createClientButton" class="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800" type="button" data-drawer-target="drawer-create-client-default" data-drawer-show="drawer-create-client-default" aria-controls="drawer-create-client-default" data-drawer-placement="right">
Add new client
</button>
</div>
</div>
</div>
<div id="clientContent">
{% include '/clients/inner.html.j2' %}
</div>
{% include '/clients/drawer_client_create.html.j2' %}
{% endblock %}

View File

@ -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>

View File

@ -1,242 +0,0 @@
{% extends "/dashboard/_base.html" %} {% 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>
{% include '/dashboard/drawer_client_create_dashboard.html.j2' %}
{% include '/dashboard/drawer_secret_create_dashboard.html.j2' %}
</div>
{% endblock %}

View File

@ -1,24 +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">
<main>
{% block content %}
{% endblock %}
</main>
</div>
</div>
{% include '/dashboard/_scripts.html' %}
</body>
</html>

View File

@ -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>

View File

@ -1,14 +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="text/javascript" src="{{ url_for('static', path="js/prism.js") }}"></script>
<script>
document.body.addEventListener("htmx:afterSwap", () => {
if (typeof window.initFlowbite === "function") {
window.initFlowbite();
}
});
</script>

View File

@ -1,21 +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
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
rel="stylesheet"
/>

View File

@ -1,116 +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>
<svg
class="w-6 h-6 text-gray-800 dark:text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0 0a8.949 8.949 0 0 0 4.951-1.488A3.987 3.987 0 0 0 13 16h-2a3.987 3.987 0 0 0-3.951 3.512A8.948 8.948 0 0 0 12 21Zm3-11a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</button>
</div>
<!-- Dropdown menu -->
<div
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded shadow dark:bg-gray-700 dark:divide-gray-600"
id="dropdown-2"
>
<div class="px-4 py-3" role="none">
<p class="text-sm text-gray-900 dark:text-white" role="none">
{{ user }}
</p>
</div>
<ul class="py-1" role="none">
<li>
<a
href="#"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem"
>Change Password</a
>
</li>
<li>
<a
href="/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>

View File

@ -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>

View File

@ -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 %}

View File

@ -1,3 +0,0 @@
<p class="mt-2 text-sm text-green-600 dark:text-red-500">
<span class="font-medium">{{ message }}</span>
</p>

View File

@ -1,3 +0,0 @@
<p class="mt-2 text-sm text-green-600 dark:text-green-500">
<span class="font-medium">{{ message }}</span>
</p>

View File

@ -1,70 +0,0 @@
{% extends "/shared/_base.html" %} {% block content %} {% if login_error %}
<div class="flex bg-gray-100">
<div
class="flex w-full items-center p-4 mb-4 text-sm text-red-800 border border-red-300 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400 dark:border-red-800"
role="alert"
>
<svg
class="shrink-0 inline w-4 h-4 me-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"
/>
</svg>
<span class="sr-only">Info</span>
<div>
<span class="font-medium">{{ login_error.title }}</span>
{{login_error.message}}
</div>
</div>
</div>
{% endif %}
<div class="min-h-screen bg-gray-100 flex items-center justify-center p-4">
<div class="max-w-md w-full bg-white rounded-xl shadow-lg p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">Sign In</h2>
<form class="space-y-4" action="/login" method="POST">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1"
>Username</label
>
<input
type="text"
name="username"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
placeholder="Username"
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>
</div>
</div>
{% endblock %}

View File

@ -1,3 +0,0 @@
{% for client in clients %}
<option value="{{ client.id }}">{{ client.name }}</option>
{% endfor %}

View File

@ -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="#secretsContent">
{% include '/secrets/drawer_secret_create_inner.html.j2' %}
</form>
</div>

View File

@ -1,118 +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
class="bottom-0 left-0 flex justify-center w-full pb-4 space-x-4 md:px-4 md:absolute"
>
<button
type="submit"
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
Add Secret
</button>
<button
type="button"
data-drawer-dismiss="drawer-create-secret-default"
aria-controls="drawer-create-secret-default"
class="inline-flex w-full justify-center text-gray-500 items-center bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-primary-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"
>
<svg
aria-hidden="true"
class="w-5 h-5 -ml-1 sm:mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
Cancel
</button>
</div>
</div>

View File

@ -1,45 +0,0 @@
{% extends "/dashboard/_base.html" %} {% block content %}
<div class="p-4 bg-white block sm:flex items-center justify-between border-b border-gray-200 lg:mt-1.5 dark:bg-gray-800 dark:border-gray-700">
<div class="w-full mb-1">
<div class="mb-4">
<nav class="flex mb-5" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 text-sm font-medium md:space-x-2">
<li class="inline-flex items-center">
<a href="/" class="inline-flex items-center text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-white">
<svg class="w-5 h-5 mr-2.5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg>
Home
</a>
</li>
<li>
<div class="flex items-center">
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg>
<span class="ml-1 text-gray-400 md:ml-2 dark:text-gray-500" aria-current="page">Secrets</span>
</svg>
</div>
</li>
</ol>
</nav>
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">Secrets</h1>
</div>
<div class="items-center justify-between block sm:flex">
<div class="flex items-center mb-4 sm:mb-0">
<label for="secret-search" class="sr-only">Search</label>
<div class="relative w-48 mt-1 sm:w-64 xl:w-96">
<input type="search" name="query" id="secret-search" class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" placeholder="Search for secrets" hx-post="/secrets/query" hx-trigger="keyup changed delay:500ms, query" hx-target="#secretsContent">
</div>
</div>
<button id="createSecretButton" class="text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-primary-600 dark:hover:bg-primary-700 focus:outline-none dark:focus:ring-primary-800" type="button" data-drawer-target="drawer-create-secret-default" data-drawer-show="drawer-create-secret-default" aria-controls="drawer-create-secret-default" data-drawer-placement="right">
Add new secret
</button>
</div>
</div>
</div>
<div id="secretsContent">
{% include '/secrets/inner.html.j2' %}
</div>
{% include '/secrets/drawer_secret_create.html.j2' %}
{% endblock %}

View File

@ -1,36 +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">
Name
</th>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Clients associated
</th>
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700">
{% for secret in secrets %}
{% include '/secrets/secret.html.j2'%}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% for secret in secrets %}
{% include '/secrets/modal_client_secret.html.j2' %}
{% endfor %}

View File

@ -1,122 +0,0 @@
<div
id="client-secret-modal-{{ secret.name }}"
tabindex="-1"
aria-hidden="true"
class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full"
>
<div class="relative p-4 w-full max-w-md max-h-full">
<div class="relative bg-white rounded-lg shadow-sm dark:bg-gray-700">
<div
class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600 border-gray-200"
>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Edit Client Access
</h3>
<button
type="button"
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
data-modal-hide="client-secret-modal-{{ secret.name }}"
>
<svg
class="w-3 h-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span class="sr-only">Close modal</span>
</button>
</div>
<div class="p-4 md:p-5">
{% if secret.clients %}
<div class="space-y-4">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Existing clients with access
</h3>
{% for client in secret.clients %}
<span
class="inline-flex items-center px-2 py-1 me-2 text-sm font-medium text-red-800 bg-red-100 rounded-sm dark:bg-red-900 dark:text-red-300 pill-client-secret"
id="client-secret-{{ secret.name }}-pill-{{ client.name }}"
>{{ client.name }}
<button
type="button"
class="inline-flex items-center p-1 ms-2 text-sm text-gray-400 bg-transparent rounded-xs hover:bg-gray-200 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-gray-300"
aria-label="Remove"
hx-delete="/secrets/{{ secret.name }}/clients/{{ client.id }}"
hx-target="#secretsContent"
hx-confirm="Remove client {{ client.name }} from secret {{secret.name}}?"
id="btn-remove-client-{{ client.name }}-secret-{{ secret.name }}"
>
<svg
class="w-2 h-2"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span class="sr-only">Remove badge</span>
</button>
</span>
{% endfor %}
</div>
{% endif %}
<form
class="space-y-4"
hx-post="/secrets/{{ secret.name }}/clients/"
hx-target="#secretsContent"
>
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
Give client access
</h3>
<label
for="client"
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>Client
</label>
<select
name="client"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
id="sel-add-client-secret-{{ secret.name }}"
>
<option selected="selected">
Select clients to assign the secret to
</option>
{% for client in clients %}
{% if client.id|string not in secret.clients|map(attribute='id')|list %}
<option value="{{ client.id }}">{{ client.name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<button
type="submit"
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
>
Give Access
</button>
</div>
</form>
</div>
</div>
</div>
</div>

View File

@ -1,73 +0,0 @@
<tr
class="hover:bg-gray-100 dark:hover:bg-gray-700"
id="secret-{{ secret.id }}"
>
<td
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
>{{- secret.name -}}</td>
<td
class="max-w-sm p-4 overflow-hidden text-base font-normal text-gray-500 truncate xl:max-w-xs dark:text-gray-400 secret-client-list"
id="secret-client-list-{{ secret.name }}"
>
{% if secret.clients %}
{% for client in secret.clients %}
<span class="bg-gray-100 text-gray-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-sm me-2 dark:bg-gray-700 dark:text-gray-400 border border-gray-500 ">
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm-2 9a4 4 0 0 0-4 4v1a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-1a4 4 0 0 0-4-4h-4Z" clip-rule="evenodd"/>
</svg>
{{- client.name -}}
</span>
{% endfor %}
{% else %}
<p class="italic font-small">No clients</p>
{% endif %}
</td>
<td class="p-4 space-x-2 whitespace-nowrap">
<button
type="button"
data-modal-target="client-secret-modal-{{secret.name}}" data-modal-toggle="client-secret-modal-{{ secret.name }}"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
id="manage-client-access-btn-{{ secret.name }}"
>
<svg
class="w-4 h-4 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"
></path>
<path
fill-rule="evenodd"
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
clip-rule="evenodd"
></path>
</svg>
Manage Client Access
</button>
<button
type="button"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-red-700 rounded-lg hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900"
hx-delete="/secrets/{{ secret.name }}"
hx-confirm="Are you sure you want to delete the secret {{ secret.name }}?"
hx-target="#secretsContent"
id="delete-secret-btn-{{ secret.name }}"
>
<svg
class="w-4 h-4 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
></path>
</svg>
Delete item
</button>
</td>
</tr>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,6 +0,0 @@
{% extends "/shared/_base.html" %} {% block content %}
<h1>Hooray!</h1>
<p>It worked!</p>
<p>Welcome, {{ user.username }}</p>
{% endblock %}

View File

@ -1 +0,0 @@
"""Frontend views."""

View File

@ -1,113 +0,0 @@
"""Audit view factory."""
# pyright: reportUnusedFunction=false
import logging
import math
from typing import Annotated, cast
from fastapi import APIRouter, Depends, Request, Response
from pydantic import BaseModel
from sshecret.backend import AuditFilter, Operation
from sshecret_admin.auth import User
from sshecret_admin.services import AdminBackend
from ..dependencies import FrontendDependencies
LOG = logging.getLogger(__name__)
class PagingInfo(BaseModel):
page: int
limit: int
total: int
offset: int = 0
@property
def first(self) -> int:
"""The first result number."""
return self.offset + 1
@property
def last(self) -> int:
"""Return the last result number."""
return self.offset + self.limit
@property
def total_pages(self) -> int:
"""Return total pages."""
return math.ceil(self.total / self.limit)
def create_router(dependencies: FrontendDependencies) -> APIRouter:
"""Create clients router."""
app = APIRouter()
templates = dependencies.templates
async def resolve_audit_entries(
request: Request,
current_user: User,
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)
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",
"entries": audit_log.results,
"user": current_user.username,
"page_info": page_info,
"operations": operations,
},
)
@app.get("/audit/")
async def get_audit_entries(
request: Request,
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
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[User, Depends(dependencies.get_user_from_access_token)],
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

View File

@ -1,182 +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 sqlmodel import Session
from sshecret_admin.services import AdminBackend
from starlette.datastructures import URL
from sshecret_admin.auth import (
User,
authenticate_user,
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
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:
login_error = LoginError(title=error_title, message=error_message)
return templates.TemplateResponse(
request,
"login.html",
{
"page_title": "Login",
"page_description": "Login page.",
"login_error": login_error,
},
)
@app.post("/login")
async def login_user(
request: Request,
response: Response,
session: Annotated[Session, Depends(dependencies.get_db_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 = authenticate_user(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,
user: Annotated[User, Depends(dependencies.get_user_from_refresh_token)],
next: Annotated[str, Query()],
):
"""Refresh tokens.
We might as well refresh the long-lived one here.
"""
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",
)
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

View File

@ -1,237 +0,0 @@
"""clients view factory."""
# pyright: reportUnusedFunction=false
import ipaddress
import logging
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Form, Request, Response
from pydantic import BaseModel, IPvAnyAddress, IPvAnyNetwork
from sshecret.backend import ClientFilter
from sshecret.backend.models import FilterType
from sshecret.crypto import validate_public_key
from sshecret_admin.auth import User
from sshecret_admin.services import AdminBackend
from ..dependencies import FrontendDependencies
LOG = logging.getLogger(__name__)
class ClientUpdate(BaseModel):
id: uuid.UUID
name: str
description: str
public_key: str
sources: str | None = None
class ClientCreate(BaseModel):
name: str
public_key: str
description: str | None
sources: str | None
def create_router(dependencies: FrontendDependencies) -> APIRouter:
"""Create clients router."""
app = APIRouter()
templates = dependencies.templates
@app.get("/clients")
async def get_clients(
request: Request,
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Response:
"""Get clients."""
clients = await admin.get_clients()
LOG.info("Clients %r", clients)
return templates.TemplateResponse(
request,
"clients/index.html.j2",
{
"page_title": "Clients",
"clients": clients,
"user": current_user.username,
},
)
@app.post("/clients/query")
async def query_clients(
request: Request,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
query: Annotated[str, Form()],
) -> Response:
"""Query for a client."""
query_filter: ClientFilter | None = None
if query:
name = f"%{query}%"
query_filter = ClientFilter(name=name, filter_name=FilterType.LIKE)
clients = await admin.get_clients(query_filter)
return templates.TemplateResponse(
request,
"clients/inner.html.j2",
{
"clients": clients,
},
)
@app.put("/clients/{id}")
async def update_client(
request: Request,
id: str,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
client: Annotated[ClientUpdate, Form()],
):
"""Update a client."""
original_client = await admin.get_client(id)
if not original_client:
return templates.TemplateResponse(
request, "fragments/error.html", {"message": "Client not found"}
)
sources: list[IPvAnyAddress | IPvAnyNetwork] = []
if client.sources:
source_str = client.sources.split(",")
for source in source_str:
if "/" in source:
sources.append(ipaddress.ip_network(source.strip()))
else:
sources.append(ipaddress.ip_address(source.strip()))
client_fields = client.model_dump(exclude_unset=True)
del client_fields["sources"]
if sources:
client_fields["policies"] = sources
LOG.info("Fields: %r", client_fields)
updated_client = original_client.model_copy(update=client_fields)
await admin.update_client(updated_client)
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"clients/inner.html.j2",
{
"clients": clients,
},
headers=headers,
)
@app.delete("/clients/{id}")
async def delete_client(
request: Request,
id: str,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
) -> Response:
"""Delete a client."""
await admin.delete_client(id)
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"clients/inner.html.j2",
{
"clients": clients,
},
headers=headers,
)
@app.post("/clients/")
async def create_client(
request: Request,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
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(",")]
await admin.create_client(
client.name, client.public_key.rstrip(), client.description, sources
)
clients = await admin.get_clients()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"clients/inner.html.j2",
{
"clients": clients,
},
headers=headers,
)
@app.post("/clients/validate/source")
async def validate_client_source(
request: Request,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
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,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
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."},
)
return app

View File

@ -1,76 +0,0 @@
"""Front page view factory."""
# pyright: reportUnusedFunction=false
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sshecret_admin.auth import User
from sshecret_admin.services import AdminBackend
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
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,
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
):
"""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)
return templates.TemplateResponse(
request,
"dashboard.html",
{
"page_title": "sshecret",
"user": current_user.username,
"stats": stats,
"last_login_events": last_login_events,
"last_audit_events": last_audit_events,
},
)
return app

View File

@ -1,181 +0,0 @@
#!/usr/bin/env python3
# pyright: reportUnusedFunction=false
import logging
import secrets as pysecrets
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Form, Request
from pydantic import BaseModel, BeforeValidator, Field
from sshecret_admin.auth import User
from sshecret_admin.services import AdminBackend
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()
templates = dependencies.templates
@app.get("/secrets/")
async def get_secrets(
request: Request,
current_user: Annotated[User, Depends(dependencies.get_user_from_access_token)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Get secrets index page."""
secrets = await admin.get_detailed_secrets()
clients = await admin.get_clients()
return templates.TemplateResponse(
request,
"secrets/index.html.j2",
{
"page_title": "Secrets",
"secrets": secrets,
"user": current_user.username,
"clients": clients,
},
)
@app.post("/secrets/")
async def add_secret(
request: Request,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
secret: Annotated[CreateSecret, Form()],
):
"""Add secret."""
LOG.info("secret: %s", secret.model_dump_json(indent=2))
clients = await admin.get_clients()
if secret.value:
value = secret.value
else:
value = pysecrets.token_urlsafe(32)
await admin.add_secret(secret.name, value, secret.clients)
secrets = await admin.get_detailed_secrets()
return templates.TemplateResponse(
request,
"secrets/inner.html.j2",
{
"secrets": secrets,
"clients": clients,
},
)
@app.delete("/secrets/{name}/clients/{id}")
async def remove_client_secret_access(
request: Request,
name: str,
id: str,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Remove a client's access to a secret."""
await admin.delete_client_secret(id, name)
clients = await admin.get_clients()
secrets = await admin.get_detailed_secrets()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/inner.html.j2",
{"clients": clients, "secret": secrets},
headers=headers,
)
@app.post("/secrets/{name}/clients/")
async def add_secret_to_client(
request: Request,
name: str,
client: Annotated[str, Form()],
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Add a secret to a client."""
await admin.create_client_secret(client, name)
clients = await admin.get_clients()
secrets = await admin.get_detailed_secrets()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/inner.html.j2",
{
"clients": clients,
"secrets": secrets,
},
headers=headers,
)
@app.delete("/secrets/{name}")
async def delete_secret(
request: Request,
name: str,
_current_user: Annotated[
User, Depends(dependencies.get_user_from_access_token)
],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
):
"""Delete a secret."""
await admin.delete_secret(name)
clients = await admin.get_clients()
secrets = await admin.get_detailed_secrets()
headers = {"Hx-Refresh": "true"}
return templates.TemplateResponse(
request,
"secrets/inner.html.j2",
{
"clients": clients,
"secrets": secrets,
},
headers=headers,
)
return app

View File

@ -4,27 +4,50 @@ 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,
AuditFilter,
AuditListResult,
Client,
ClientFilter,
Secret,
SshecretBackend,
Operation,
SubSystem,
)
from sshecret.backend.models import DetailedSecrets
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 SecretView
from .models import (
ClientSecretGroup,
ClientSecretGroupList,
GroupReference,
SecretClientMapping,
SecretListView,
SecretGroup,
SecretView,
)
class ClientManagementError(Exception):
@ -46,22 +69,65 @@ class BackendUnavailableError(ClientManagementError):
LOG = logging.getLogger(__name__)
def add_clients_to_secret_group(
group: SecretGroup,
client_secret_mapping: dict[str, DetailedSecrets],
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_ref,
)
for entry in group.entries:
secret_entries = SecretClientMapping(name=entry)
if details := client_secret_mapping.get(entry):
secret_entries.clients = details.clients
client_secret_group.entries.append(secret_entries)
for subgroup in group.children:
client_secret_group.children.append(
add_clients_to_secret_group(
subgroup, client_secret_mapping, client_secret_group
)
)
if not parent and group.parent_group:
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."""
@ -76,13 +142,28 @@ class AdminBackend:
except Exception as e:
raise BackendUnavailableError() from e
async def _get_client(self, name: str) -> Client | None:
"""Get a client from the backend."""
return await self.backend.get_client(name)
async def get_clients_terse(self) -> list[ClientReference]:
"""Get a list of client ids and names."""
return await self.backend.get_client_terse()
async def _verify_client_exists(self, name: str) -> None:
async def query_clients(
self, filter: ClientFilter | None = None
) -> ClientQueryResult:
"""Query clients."""
try:
return await self.backend.query_clients(filter)
except ClientManagementError:
raise
except Exception as e:
raise BackendUnavailableError() from e
async def _get_client(self, idname: KeySpec) -> Client | None:
"""Get a client from the backend."""
return await self.backend.get_client(idname)
async def _verify_client_exists(self, idname: KeySpec) -> None:
"""Check that a client exists."""
client = await self.backend.get_client(name)
client = await self.backend.get_client(idname)
if not client:
raise ClientNotFoundError()
return None
@ -96,7 +177,7 @@ class AdminBackend:
except Exception as e:
raise BackendUnavailableError() from e
async def get_client(self, name: str) -> Client | None:
async def get_client(self, name: KeySpec) -> Client | None:
"""Get a client from the backend."""
try:
return await self._get_client(name)
@ -105,6 +186,10 @@ class AdminBackend:
except Exception as e:
raise BackendUnavailableError() from e
async def get_client_count(self, filter: ClientFilter | None = None) -> int:
"""Count the clients, optionally with filter."""
return await self.backend.get_client_count(filter)
async def _create_client(
self,
name: str,
@ -135,11 +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: str, new_key: str, password_manager: PasswordContext
self,
name: KeySpec,
new_key: str,
password_manager: AsyncSecretContext,
) -> list[str]:
"""Update client public key."""
LOG.info(
@ -152,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
@ -166,10 +261,10 @@ class AdminBackend:
return updated_secrets
async def update_client_public_key(self, name: str, new_key: str) -> list[str]:
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
)
@ -201,18 +296,18 @@ class AdminBackend:
except Exception as e:
raise BackendUnavailableError() from e
async def update_client_sources(self, name: str, sources: list[str]) -> None:
async def update_client_sources(self, name: KeySpec, sources: list[str]) -> None:
"""Update client sources."""
try:
await self.backend.update_client_sources(name, sources)
except Exception as e:
raise BackendUnavailableError() from e
async def _delete_client(self, name: str) -> None:
async def _delete_client(self, name: KeySpec) -> None:
"""Delete client."""
await self.backend.delete_client(name)
async def delete_client(self, name: str) -> None:
async def delete_client(self, name: KeySpec) -> None:
"""Delete client."""
try:
await self._delete_client(name)
@ -221,30 +316,41 @@ class AdminBackend:
except Exception as e:
raise BackendUnavailableError() from e
async def delete_client_secret(self, client_name: str, secret_name: str) -> None:
async def delete_client_secret(
self, client_name: KeySpec, secret_name: KeySpec
) -> None:
"""Delete a secret from a client."""
try:
await self.backend.delete_client_secret(client_name, secret_name)
except Exception as e:
raise BackendUnavailableError() from e
async def _get_secrets(self) -> list[Secret]:
async def _get_secrets(self) -> list[SecretListView]:
"""Get secrets.
This fetches the secret to client mapping from backend, and adds secrets from the password manager.
"""
with self.password_manager() as password_manager:
all_secrets = password_manager.get_available_secrets()
backend_secrets = await self.backend.get_secrets()
async with self.secrets_manager() as password_manager:
admin_secrets = await password_manager.get_available_secrets()
secrets = await self.backend.get_secrets()
backend_secret_names = [secret.name for secret in secrets]
for secret in all_secrets:
if secret not in backend_secret_names:
secrets.append(Secret(name=secret, clients=[]))
secrets: dict[str, SecretListView] = {}
for secret in backend_secrets:
secrets[secret.name] = SecretListView(
name=secret.name, unmanaged=True, clients=secret.clients
)
return secrets
for secret_name in admin_secrets:
if secret_name in secrets:
secrets[secret_name].unmanaged = False
continue
secrets[secret_name] = SecretListView(
name=secret_name, unmanaged=False, clients=[]
)
async def get_secrets(self) -> list[Secret]:
return list(secrets.values())
async def get_secrets(self) -> list[SecretListView]:
"""Get secrets from backend."""
try:
return await self._get_secrets()
@ -258,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]
@ -278,6 +384,142 @@ class AdminBackend:
except Exception as e:
raise BackendUnavailableError() from e
async def add_secret_group(
self,
group_name: str,
description: str | None = None,
parent_group: str | None = None,
) -> None:
"""Add secret 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."""
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) -> str:
"""Move a group.
If parent_group is None, it will be moved to the root.
Returns the new path of the 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."""
async with self.secrets_manager() as password_manager:
await password_manager.set_group_description(group_name, description)
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)
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,
group_filter: str | None = None,
regex: bool = True,
flat: bool = False,
) -> ClientSecretGroupList:
"""Get secret groups.
The starting group can be filtered with the group_name argument, which
may be a regular expression.
Groups are returned in a tree, unless flat is True.
"""
all_secrets = await self.backend.get_detailed_secrets()
secrets_mapping = {secret.name: secret for secret in all_secrets}
async with self.secrets_manager() as password_manager:
if flat:
all_groups = await password_manager.get_secret_group_list(
group_filter, regex=regex
)
else:
all_groups = await password_manager.get_secret_groups(
group_filter, regex=regex
)
ungrouped = await password_manager.get_ungrouped_secrets()
all_admin_secrets = await password_manager.get_available_secrets()
group_result: list[ClientSecretGroup] = []
for group in all_groups:
# We have to do this recursively.
group_result.append(add_clients_to_secret_group(group, secrets_mapping))
result = ClientSecretGroupList(groups=group_result)
if group_filter:
return result
ungrouped_clients: list[SecretClientMapping] = []
for name in ungrouped:
mapping = SecretClientMapping(name=name)
if client_mapping := secrets_mapping.get(name):
mapping.clients = client_mapping.clients
ungrouped_clients.append(mapping)
# We need to process unmanaged secrets too.
unmanaged_secrets = [
secret for secret in all_secrets if secret.name not in all_admin_secrets
]
for secret in unmanaged_secrets:
ungrouped_clients.append(
SecretClientMapping(
name=secret.name, unmanaged=True, clients=secret.clients
)
)
result.ungrouped = ungrouped_clients
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)
if matches.groups:
return matches.groups[0]
return None
async def get_secret_group_by_path(self, path: str) -> ClientSecretGroup | None:
"""Get a group based on its path."""
async with self.secrets_manager() as password_manager:
secret_group = await password_manager.get_secret_group(path)
if not secret_group:
return None
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 get_secret(self, name: str) -> SecretView | None:
"""Get secrets from backend."""
try:
@ -287,17 +529,33 @@ class AdminBackend:
except Exception as e:
raise BackendUnavailableError() from e
async def _get_secret(self, name: str) -> SecretView | None:
async def _get_secret(
self, name: str, secret_id: str | None = None
) -> SecretView | None:
"""Get a secret, including the actual unencrypted value and clients."""
with self.password_manager() as password_manager:
secret = password_manager.get_secret(name)
secret: str | None = None
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)
if not secret:
return None
secret_view = SecretView(name=name, secret=secret)
secret_mapping = await self.backend.get_secret(name)
secret_view = SecretView(name=name, secret=secret, group=secret_group)
idname: KeySpec = name
if secret_id:
idname = ("id", secret_id)
secret_mapping = await self.backend.get_secret(idname)
if secret_mapping:
secret_view.clients = 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
@ -312,52 +570,80 @@ 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:
return
for client in secret_mapping.clients:
LOG.info("Deleting secret %s from client %s", name, client)
await self.backend.delete_client_secret(client, name)
await self.backend.delete_client_secret(("id", client.id), name)
async def _add_secret(
self, name: str, value: str, clients: list[str] | None, update: bool = False
self,
name: str,
value: str,
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)
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)
if secret_map:
clients = secret_map.clients
clients = [ref.name for ref in secret_map.clients]
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, name: str, value: str, clients: list[str] | None = None
self,
name: str,
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, value, clients)
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."""
@ -368,30 +654,38 @@ class AdminBackend:
except Exception as e:
raise BackendUnavailableError() from e
async def _create_client_secret(self, client_name: str, secret_name: str) -> None:
async def _create_client_secret(
self, client_idname: KeySpec, secret_name: str
) -> None:
"""Create client secret."""
client = await self.get_client(client_name)
client = await self.get_client(client_idname)
if not client:
raise ClientNotFoundError()
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()
public_key = load_public_key(client.public_key.encode())
encrypted = encrypt_string(secret, public_key)
await self.backend.create_client_secret(client_name, secret_name, encrypted)
await self.backend.create_client_secret(client_idname, secret_name, encrypted)
async def create_client_secret(self, client_name: str, secret_name: str) -> None:
async def create_client_secret(
self, client_idname: KeySpec, secret_name: str
) -> None:
"""Create client secret."""
try:
await self._create_client_secret(client_name, secret_name)
await self._create_client_secret(client_idname, secret_name)
except ClientManagementError:
raise
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."""
@ -466,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))

View File

@ -1,119 +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
from .master_password import decrypt_master_password
from sshecret_admin.core.settings import AdminServerSettings
LOG = logging.getLogger(__name__)
NO_USERNAME = "NO_USERNAME"
DEFAULT_LOCATION = "keepass.kdbx"
def create_password_db(location: Path, password: str) -> None:
"""Create the password database."""
LOG.info("Creating password database at %s", location)
pykeepass.create_database(str(location.absolute()), password=password)
class PasswordContext:
"""Password Context class."""
def __init__(self, keepass: pykeepass.PyKeePass) -> None:
"""Initialize password context."""
self.keepass: pykeepass.PyKeePass = keepass
def add_entry(self, entry_name: str, secret: str, overwrite: bool = False) -> None:
"""Add an entry.
Specify overwrite=True to overwrite the existing secret value, if it exists.
"""
entry = cast(
"pykeepass.entry.Entry | None",
self.keepass.find_entries(title=entry_name, first=True),
)
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", entry_name)
entry = self.keepass.add_entry(
destination_group=self.keepass.root_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 = cast(
"pykeepass.entry.Entry | None",
self.keepass.find_entries(title=entry_name, first=True),
)
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_available_secrets(self) -> list[str]:
"""Get the names of all secrets in the database."""
entries = 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()
@contextmanager
def _password_context(location: Path, password: str) -> Iterator[PasswordContext]:
"""Open the password context."""
database = pykeepass.PyKeePass(str(location.absolute()), password=password)
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

View File

@ -1,84 +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)

View File

@ -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,8 +10,11 @@ from pydantic import (
Field,
IPvAnyAddress,
IPvAnyNetwork,
field_validator,
model_validator,
)
from sshecret.crypto import validate_public_key
from sshecret.backend.models import AuditFilter, ClientReference
def public_key_validator(value: str) -> str:
@ -24,15 +28,30 @@ class SecretListView(BaseModel):
"""Model containing a list of all available secrets."""
name: str
unmanaged: bool = False
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
clients: list[str] = Field(default_factory=list) # Clients that have access to it.
secret: str | None
group: GroupReference | None = None
clients: list[ClientReference] = Field(
default_factory=list
) # Clients that have access to it.
class UpdateKeyModel(BaseModel):
@ -59,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)
@ -75,7 +95,7 @@ class SecretUpdate(BaseModel):
value: str | AutoGenerateOpts = Field(
description="Secret as string value or auto-generated with optional length",
examples=["MySecretString", {"auto_generate": True, "length": 32}]
examples=["MySecretString", {"auto_generate": True, "length": 32}],
)
def get_secret(self) -> str:
@ -85,7 +105,7 @@ class SecretUpdate(BaseModel):
"""
if isinstance(self.value, str):
return self.value
secret = secrets.token_urlsafe(32)[:self.value.length]
secret = secrets.token_urlsafe(32)[: self.value.length]
return secret
@ -93,7 +113,11 @@ class SecretCreate(SecretUpdate):
"""Model to create a secret."""
name: str
clients: list[str] | None = Field(default=None, description="Assign the secret to a list of clients.")
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(
json_schema_extra={
@ -101,12 +125,150 @@ class SecretCreate(SecretUpdate):
{
"name": "MySecret",
"clients": ["client-1", "client-2"],
"value": { "auto_generate": True, "length": 32 }
"group": None,
"value": {"auto_generate": True, "length": 32},
},
{
"name": "MySecret",
"group": "MySecretGroup",
"value": "mysecretstring",
}
},
]
}
)
class SecretGroup(BaseModel):
"""A secret group."""
id: uuid.UUID
name: str
path: str
description: str | None = None
parent_group: "SecretGroup | None" = None
children: list["SecretGroup"] = Field(default_factory=list)
entries: list[str] = Field(default_factory=list)
class SecretClientMapping(BaseModel):
"""Secret name with list of clients."""
name: str # name of secret
unmanaged: bool = False
clients: list[ClientReference] = Field(default_factory=list)
class ClientSecretGroup(BaseModel):
"""Client secrets grouped."""
id: uuid.UUID
group_name: str
path: str
description: str | 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 = 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

View File

@ -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

View File

@ -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

View File

@ -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;
}

View File

@ -1,23 +0,0 @@
<!doctype html>
<html class="no-js" lang="">
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Static in lib</title>
<meta name="description" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- Place favicon.ico in the root directory -->
</head>
<body>
<!--[if lt IE 8]>
<p class="browserupgrade">
You are using an <strong>outdated</strong> browser. Please
<a href="http://browsehappy.com/">upgrade your browser</a> to improve
your experience.
</p>
<![endif]-->
<h1>I'm inside the package</h1>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -1,54 +0,0 @@
const sidebar = document.getElementById("sidebar");
if (sidebar) {
const toggleSidebarMobile = (
sidebar,
sidebarBackdrop,
toggleSidebarMobileHamburger,
toggleSidebarMobileClose,
) => {
sidebar.classList.toggle("hidden");
sidebarBackdrop.classList.toggle("hidden");
toggleSidebarMobileHamburger.classList.toggle("hidden");
toggleSidebarMobileClose.classList.toggle("hidden");
};
const toggleSidebarMobileEl = document.getElementById("toggleSidebarMobile");
const sidebarBackdrop = document.getElementById("sidebarBackdrop");
const toggleSidebarMobileHamburger = document.getElementById(
"toggleSidebarMobileHamburger",
);
const toggleSidebarMobileClose = document.getElementById(
"toggleSidebarMobileClose",
);
// const toggleSidebarMobileSearch = document.getElementById(
// "toggleSidebarMobileSearch",
// );
// toggleSidebarMobileSearch.addEventListener("click", () => {
// toggleSidebarMobile(
// sidebar,
// sidebarBackdrop,
// toggleSidebarMobileHamburger,
// toggleSidebarMobileClose,
// );
// });
toggleSidebarMobileEl.addEventListener("click", () => {
toggleSidebarMobile(
sidebar,
sidebarBackdrop,
toggleSidebarMobileHamburger,
toggleSidebarMobileClose,
);
});
// sidebarBackdrop.addEventListener("click", () => {
// toggleSidebarMobile(
// sidebar,
// sidebarBackdrop,
// toggleSidebarMobileHamburger,
// toggleSidebarMobileClose,
// );
// });
}

View File

@ -2,8 +2,8 @@
from collections.abc import AsyncGenerator, Callable, Generator, Awaitable
from sqlalchemy.orm import Session
from fastapi import Request
from sqlmodel import Session
from sshecret_admin.admin_backend import AdminBackend
from sshecret_admin.auth_models import User

View File

@ -1,6 +1,7 @@
module.exports = {
content: [
"./src/sshecret_admin/templates/**/*.html",
"./src/sshecret_admin/**/*.html",
"./src/sshecret_admin/**/*.html.j2",
"./src/sshecret_admin/static/**/*.js",
],
safelist: [

View File

@ -51,7 +51,7 @@ def upgrade() -> None:
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.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
sa.UniqueConstraint('name', name="uq_client_name")
)
op.create_table('client_access_policy',
sa.Column('id', sa.Uuid(), nullable=False),

View File

@ -0,0 +1,33 @@
"""Implement db structures for internal password manager
Revision ID: 1657c5d25d2c
Revises: b4e135ff347a
Create Date: 2025-06-21 07:22:17.792528
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1657c5d25d2c'
down_revision: Union[str, None] = 'b4e135ff347a'
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.add_column('client', sa.Column('is_system', sa.Boolean(), nullable=False, default=False, server_default="0"))
op.add_column('client_secret', sa.Column('is_system', sa.Boolean(), nullable=False, default=False, server_default="0"))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('client_secret', 'is_system')
op.drop_column('client', 'is_system')

View File

@ -0,0 +1,44 @@
"""Remove secret key from password database
Revision ID: 71f7272a6ee1
Revises: 1657c5d25d2c
Create Date: 2025-06-22 18:42:53.207334
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '71f7272a6ee1'
down_revision: Union[str, None] = '1657c5d25d2c'
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.drop_table('managed_secret')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('managed_secret',
sa.Column('id', sa.CHAR(length=32), nullable=False),
sa.Column('name', sa.VARCHAR(), nullable=False),
sa.Column('description', sa.VARCHAR(), nullable=True),
sa.Column('secret', sa.VARCHAR(), nullable=False),
sa.Column('client_id', sa.CHAR(length=32), nullable=True),
sa.Column('deleted', sa.BOOLEAN(), nullable=False),
sa.Column('created_at', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DATETIME(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('deleted_at', sa.DATETIME(), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['client.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###

View File

@ -0,0 +1,36 @@
"""Remove invalidated, add deleted
Revision ID: b4e135ff347a
Revises: f2dc50533f88
Create Date: 2025-06-06 08:57:47.611854
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b4e135ff347a'
down_revision: Union[str, None] = 'f2dc50533f88'
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.add_column('client_secret', sa.Column('deleted', sa.Boolean(), nullable=False))
op.add_column('client_secret', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
op.drop_column('client_secret', 'invalidated')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('client_secret', sa.Column('invalidated', sa.BOOLEAN(), nullable=False))
op.drop_column('client_secret', 'deleted_at')
op.drop_column('client_secret', 'deleted')
# ### end Alembic commands ###

View File

@ -0,0 +1,52 @@
"""Make client object better
Revision ID: c251311b64c9
Revises: 37329d9b5437
Create Date: 2025-06-04 21:49:22.638698
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "c251311b64c9"
down_revision: Union[str, None] = "37329d9b5437"
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! ###
with op.batch_alter_table("client", schema=None) as batch_op:
batch_op.add_column(sa.Column("version", sa.Integer(), nullable=False))
batch_op.add_column(sa.Column("is_active", sa.Boolean(), nullable=False))
batch_op.add_column(sa.Column("is_deleted", sa.Boolean(), nullable=False))
batch_op.add_column(
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True)
)
batch_op.add_column(sa.Column("parent_id", sa.Uuid(), nullable=True))
batch_op.drop_constraint("uq_client_name")
batch_op.create_unique_constraint("uq_client_name_version", ["name", "version"])
batch_op.create_foreign_key(
"fk_client_parent", "client", ["parent_id"], ["id"], ondelete="SET NULL"
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("client", schema=None) as batch_op:
batch_op.drop_constraint("fk_client_parent", type_="foreignkey")
batch_op.drop_constraint("uq_client_name_version", type_="unique")
batch_op.drop_column("parent_id")
batch_op.drop_column("deleted_at")
batch_op.drop_column("is_deleted")
batch_op.drop_column("is_active")
batch_op.drop_column("version")
# ### end Alembic commands ###

View File

@ -0,0 +1,27 @@
"""Rename parent to previous_version for clarity
Revision ID: f2dc50533f88
Revises: c251311b64c9
Create Date: 2025-06-05 13:24:32.465927
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'f2dc50533f88'
down_revision: Union[str, None] = 'c251311b64c9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.alter_column("client", "parent_id", new_column_name="previous_version_id")
def downgrade() -> None:
"""Downgrade schema."""
op.alter_column("client", "previous_version_id", new_column_name="parent_id")

Some files were not shown because too many files have changed in this diff Show More