Compare commits
113 Commits
4f970a3f71
...
sftp-suppo
| Author | SHA1 | Date | |
|---|---|---|---|
| 49cd23b21b | |||
| 25dfefccb0 | |||
| d6357c8a88 | |||
| 08b85ab3bb | |||
| e346949953 | |||
| f312edabd7 | |||
| 3f6b49a69b | |||
| 1156bc315e | |||
| 1362d0a289 | |||
| b35a777a89 | |||
| 73c7abeb65 | |||
| d3a0f698ac | |||
| 0059480363 | |||
| 8beefdf82f | |||
| ef8b50e302 | |||
| 33c1e7278b | |||
| f0c729cba7 | |||
| f8eac2b09c | |||
| f518723a0e | |||
| 37f381c884 | |||
| 45ae0929e6 | |||
| 3efc4d7fa5 | |||
| 412a84150e | |||
| 6a5149fd4c | |||
| 5ac4c987d3 | |||
| 736dad748b | |||
| 746f809d28 | |||
| 6faed0dbd4 | |||
| 880d556542 | |||
| 3ef659be61 | |||
| c7ecc3f365 | |||
| 82ec7fabb4 | |||
| 5985a726e3 | |||
| 4a5874d4f8 | |||
| 1cde31a023 | |||
| 4520e9a781 | |||
| 25879100a4 | |||
| 2b50c686d0 | |||
| 57e69390b2 | |||
| 23d354bc12 | |||
| cad9849019 | |||
| b4c395f0da | |||
| d55c699549 | |||
| 05775a2e1e | |||
| 9b0588679f | |||
| d9e0052003 | |||
| bf1d119bd8 | |||
| bce372a1d1 | |||
| b3debd3ed2 | |||
| 0eaa913e35 | |||
| 782ec19137 | |||
| 43d00cecb4 | |||
| d1fa6c0076 | |||
| 71d877022b | |||
| 36d04b8a33 | |||
| a834339c13 | |||
| fb6b76f7d8 | |||
| fed441743e | |||
| d86d9a9256 | |||
| 3779e93b8c | |||
| 7ad41f43d8 | |||
| aa6b55a911 | |||
| a7a09f7784 | |||
| ee1e7a16ec | |||
| 435b9dee83 | |||
| 0eb4e4a34c | |||
| ecad667521 | |||
| ba936ac645 | |||
| 773a1e2976 | |||
| 18f61631c9 | |||
| 289352d872 | |||
| f853ca81d0 | |||
| 2585eb1fb3 | |||
| 391e310b91 | |||
| b491dff4b1 | |||
| e46f6f8d4f | |||
| 5865cc450f | |||
| fc0c3fb950 | |||
| f10ae027e5 | |||
| b8cae28888 | |||
| a0adf281b5 | |||
| 061a52c90a | |||
| 86ad1a13fb | |||
| dcf0b4274c | |||
| 26ef9b45d4 | |||
| 64536b40f6 | |||
| fd2922fde8 | |||
| 6daceef913 | |||
| 96ce402f9f | |||
| 29527aad4f | |||
| 3055f5277b | |||
| 60026a485d | |||
| 197c8a7c05 | |||
| 80e2c339e3 | |||
| 458863de3d | |||
| a2ec2173ac | |||
| 090ec4dc3f | |||
| a07fba9560 | |||
| d3d99775d9 | |||
| b34c49d3e3 | |||
| d0b92b220e | |||
| 3dfd03688b | |||
| 388200fd52 | |||
| 7c65d5bb93 | |||
| e22f9b8f10 | |||
| e766b06d5c | |||
| e28276634a | |||
| 3f5d9ea545 | |||
| 6bebbee4fa | |||
| 9bf92d6743 | |||
| 9ccd2f1d4d | |||
| d866553ac1 | |||
| 0a427b6a91 |
37
.coveragerc
Normal file
37
.coveragerc
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
[run]
|
||||||
|
branch = True
|
||||||
|
source =
|
||||||
|
src/sshecret
|
||||||
|
packages/sshecret-admin/src/sshecret_admin
|
||||||
|
packages/sshecret-backend/src/sshecret_backend
|
||||||
|
packages/sshecret-sshd/src/sshecret_sshd
|
||||||
|
|
||||||
|
omit =
|
||||||
|
packages/sshecret-admin/src/sshecret_admin/frontend/*
|
||||||
|
*/__init__.py
|
||||||
|
*/types.py
|
||||||
|
*/testing.py
|
||||||
|
*/settings.py
|
||||||
|
*/main.py
|
||||||
|
*/cli.py
|
||||||
|
*/tests/*
|
||||||
|
*/test_*.py
|
||||||
|
*/conftest.py
|
||||||
|
*/site-packages/*
|
||||||
|
concurrency = thread
|
||||||
|
|
||||||
|
[report]
|
||||||
|
show_missing = True
|
||||||
|
skip_covered = True
|
||||||
|
|
||||||
|
exclude_lines =
|
||||||
|
if __name__ == .__main__.:
|
||||||
|
def __repr__
|
||||||
|
def __str__
|
||||||
|
def __eq__
|
||||||
|
def __ne__
|
||||||
|
raise NotImplementedError
|
||||||
|
except ImportError
|
||||||
|
|
||||||
|
[html]
|
||||||
|
directory = coverage_html_report
|
||||||
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.venv
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
**/.pytest_cache
|
||||||
|
**/__pycache__
|
||||||
|
.ruff_cache
|
||||||
|
**/.testing
|
||||||
|
packages/sshecret-admin/sshecret_admin.db
|
||||||
|
packages/sshecret-admin/sshecret_admin-key
|
||||||
|
packages/sshecret-admin/keepass.kdbx
|
||||||
66
.gitea/workflows/build.yml
Normal file
66
.gitea/workflows/build.yml
Normal 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 }}
|
||||||
201
LICENSE
Normal file
201
LICENSE
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
122
README.md
122
README.md
@ -1,58 +1,112 @@
|
|||||||
# Sshecret - Openssh based secrets management
|
# Sshecret - Simple Secret management using SSH and RSA Keys
|
||||||
|
|
||||||
## Motivation
|
Managing secrets within a container environment or homelab can be quite complex.
|
||||||
|
|
||||||
There are many approaches to managing secrets for services, but a lot of these
|
As with container orchestration in general, many of the available tools are targetted large enterprise installations and add significant complexity to both the management of secrets themselves, and to the consumers.
|
||||||
either assume you have one of the industry-standard systems like hashicorp vault to manage them centrally.
|
|
||||||
|
|
||||||
For enthusiasts or homelabbers this becomes overkill quickly, and end up
|
For enthusiasts or homelabbers solutions like Hashicorp Vault become overkill
|
||||||
consuming a lot more time and energy than what feels justified.
|
quickly, and end up consuming a lot more time and energy than what feels
|
||||||
|
justified.
|
||||||
|
|
||||||
This system has been created to provide a centralized solution that works well-enough.
|
This system has been created to provide a centralized solution that works well-enough.
|
||||||
|
|
||||||
One clear goal was to have all the complexity on the server-side, and be able to construct a minimal client.
|
Sshecret provides a simple, centralized solution for secret storage, and requires only tools that are commonly pre-installed on most linux system.
|
||||||
|
|
||||||
## Components
|
# Concept
|
||||||
|
The system uses RSA keys, as generated by openssh, to encrypt secrets for each client.
|
||||||
|
|
||||||
This system has been designed with modularity and extensibility in mind. It has the following building blocks:
|
It uses RSA keys as it is possible to do encryption using only the public key.
|
||||||
|
|
||||||
- Password database
|
By using a custom SSH server, the consuming servers can fetch a version of a secret encrypted specifically for them.
|
||||||
- Password input handler
|
|
||||||
- Encryption and key management
|
As the secret is encrypted with RSA keys, it can be decrypted using the openssl command which is commonly available.
|
||||||
|
|
||||||
|
This means that while the backend interface can be complex, the to access and decrypt a secret, you can use ssh, and a simple bash script.
|
||||||
|
|
||||||
|
# Components
|
||||||
|
|
||||||
|
There are three components to Sshecret:
|
||||||
|
|
||||||
|
- Password database and admin interface
|
||||||
- Client secret storage backend
|
- Client secret storage backend
|
||||||
- Custom ssh server
|
- Ssh server for clients to access
|
||||||
|
|
||||||
### Password database
|
The three systems should be deployed separately for security, with the backend system as the only central component.
|
||||||
Currently a single password database is implemented: Keepass.
|
|
||||||
|
|
||||||
Sshecret can create a database, and store your secrets in it.
|
The ssh server that the clients connect to, only has access to encrypted versions of the secrets.
|
||||||
|
|
||||||
It only uses a master password for protection, so you are responsible for
|
If it or the backend should be compromised, it wouldn't be possible to extract any clear-text secrets, since only encrypted values are stored, and each encrypted with RSA public key encryption.
|
||||||
securing the password database file. In theory, the password database file can
|
|
||||||
be disconnected after encrypting the passwords for the clients, and these two
|
|
||||||
components may be disconnected.
|
|
||||||
|
|
||||||
### Password input handler
|
## Backend
|
||||||
Passwords can be randomly generated, they can be read from stdin, or from environment variables.
|
The backend system stores the definition of each client, including their public key, and the IP addresses/networks they are allowed to connect from.
|
||||||
|
It also stores a version of each secret that the client has access to, encrypted with the public key. It has no knowledge of the unencrypted secrets.
|
||||||
|
|
||||||
Other methods can be implemented in the future.
|
Both the admin interface and the ssh server access this system over a REST API using a token-based authentication method.
|
||||||
|
|
||||||
### Client secret storage backend
|
## Password database and admin interface
|
||||||
So far only a simple JSON file based backend has been implemented. It stores one file per client.
|
The sshecret password database is based on keepass, and the admin interface is available as a simple web interface as well as a REST API.
|
||||||
The interface is flexible, and can be extended to databases or anything else really.
|
|
||||||
|
|
||||||
### Custom SSH server
|
This component is primarily responsible for storing the secrets in a keepass database and populating the backend with client-specific encrypted versions.
|
||||||
A custom SSH based on paramiko is included. This is how the clients receive the encrypted password.
|
|
||||||
The client must send a single command over the SSH session equal to the name of the secret.
|
|
||||||
|
|
||||||
If permitted to access the secret, it will returned encrypted with the client RSA public key of the client, encoded as base64.
|
The admin interface must be secured. It currently supports local user accounts, but will be expanded with OIDC support in the near future.
|
||||||
|
|
||||||
|
## Custom SSH server
|
||||||
|
To make it easy to fetch secrets sshecret includes a SSH server.
|
||||||
|
|
||||||
|
To fetch a secret, simply ssh to it using the client name as username and the
|
||||||
|
registered RSA key as authorization.
|
||||||
|
|
||||||
|
Send the command `get_secret` followed by the name of the secret. The ssh server
|
||||||
|
will check the client's public key against permitted keys, and check if the
|
||||||
|
client is allowed to connect and if the secret is available to it.
|
||||||
|
|
||||||
|
The server will answer with a base64 encoded version of the secret.
|
||||||
|
|
||||||
|
See `examples/sshecret-client.bash` for an example of a bash script that can be
|
||||||
|
used to fetch and decrypt a secret.
|
||||||
|
|
||||||
|
Out of the box, only the `get_secret` command is available, however an optional
|
||||||
|
command `register` can be enabled to allow registration of a client using the
|
||||||
|
SSH interface to make it easy to onboard new clients automatically.
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
|
||||||
|
Each subsystem is set up using environment variables.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
The location and type of database may be configured.
|
||||||
|
For now, only sqlite is officially supported.
|
||||||
|
|
||||||
|
The value shown below is the default value. In a docker setup, you may want to
|
||||||
|
create a volume and configure this to a path within the volume to be able to
|
||||||
|
perform backups.
|
||||||
|
|
||||||
|
SSHECRET_BACKEND_DATABASE=/path/to/sshecret.db
|
||||||
|
|
||||||
|
|
||||||
|
While the backend can be placed behind reverse proxy and served with HTTPS, it's
|
||||||
|
probably better to have keep it inside an internal container network.
|
||||||
|
|
||||||
|
## Admin
|
||||||
|
The backend must be generated on the backend before setting up the admin.
|
||||||
|
|
||||||
|
SSHECRET_BACKEND_URL=http://backend:8022
|
||||||
|
SSHECRET_ADMIN_BACKEND_TOKEN: mySuperSecretBackendToken
|
||||||
|
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
The system can be deplyed using docker or any other container runtime.
|
||||||
|
|
||||||
|
See the examples in the `docker/` folder.
|
||||||
|
|
||||||
This allows the client to decrypt and get the clear text value easily.
|
|
||||||
|
|
||||||
# FAQ
|
# FAQ
|
||||||
## Why not use Age?
|
## Why not use Age?
|
||||||
I like age a lot, and it's ability to use more ssh key types is certainly a winner feature.
|
|
||||||
However, one goal here is to be able to construct a client with minimal dependencies, and that speaks in favor of the current solution.
|
I like age a lot, and it's ability to use more ssh key types is certainly a
|
||||||
|
winner feature. However, a clear goal of this project is to be able to construct
|
||||||
|
a client with minimal dependencies.
|
||||||
|
|
||||||
Using just RSA keys, you can construct a client using only the following tools:
|
Using just RSA keys, you can construct a client using only the following tools:
|
||||||
- base64
|
- base64
|
||||||
@ -60,3 +114,5 @@ Using just RSA keys, you can construct a client using only the following tools:
|
|||||||
- ssh
|
- ssh
|
||||||
|
|
||||||
This means that you can create a client using just a shell script.
|
This means that you can create a client using just a shell script.
|
||||||
|
|
||||||
|
If age were to be used, the age tool would have to be installed on each client sytem.
|
||||||
|
|||||||
39
docker/Dockerfile.admin
Normal file
39
docker/Dockerfile.admin
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# this Dockerfile should be built from the repo root
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
|
||||||
|
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
ENV UV_PYTHON_DOWNLOADS=0
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY . /build
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
EXPOSE 8822
|
||||||
|
|
||||||
|
VOLUME /opt/sshecret-admin
|
||||||
|
|
||||||
|
WORKDIR /opt/sshecret-admin
|
||||||
|
|
||||||
|
CMD ["/entrypoint.sh"]
|
||||||
30
docker/Dockerfile.backend
Normal file
30
docker/Dockerfile.backend
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# this Dockerfile should be built from the repo root
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
|
||||||
|
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
ENV UV_PYTHON_DOWNLOADS=0
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY . /build
|
||||||
|
|
||||||
|
RUN uv build --package sshecret
|
||||||
|
RUN uv build --package sshecret-backend
|
||||||
|
|
||||||
|
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||||
|
|
||||||
|
COPY --from=builder --chown=app:app /build/dist /opt/sshecret
|
||||||
|
|
||||||
|
RUN uv pip install --system /opt/sshecret/sshecret-*.whl
|
||||||
|
RUN uv pip install --system /opt/sshecret/sshecret_backend-*.whl
|
||||||
|
|
||||||
|
COPY packages/sshecret-backend /opt/sshecret-backend
|
||||||
|
COPY docker/backend.entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
WORKDIR /opt/sshecret-backend
|
||||||
|
|
||||||
|
VOLUME /opt/sshecret-backend-db
|
||||||
|
|
||||||
|
EXPOSE 8022
|
||||||
|
|
||||||
|
CMD ["/entrypoint.sh"]
|
||||||
26
docker/Dockerfile.sshd
Normal file
26
docker/Dockerfile.sshd
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# this Dockerfile should be built from the repo root
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
|
||||||
|
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
ENV UV_PYTHON_DOWNLOADS=0
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY . /build
|
||||||
|
|
||||||
|
RUN uv build --package sshecret
|
||||||
|
RUN uv build --package sshecret-sshd
|
||||||
|
|
||||||
|
FROM python:3.13-slim-bookworm
|
||||||
|
|
||||||
|
COPY --from=builder --chown=app:app /build/dist /opt/sshecret
|
||||||
|
|
||||||
|
RUN pip install /opt/sshecret/sshecret-*.whl
|
||||||
|
RUN pip install /opt/sshecret/sshecret_sshd-*.whl
|
||||||
|
|
||||||
|
WORKDIR /opt/sshecret-sshd
|
||||||
|
|
||||||
|
VOLUME /opt/sshecret-sshd
|
||||||
|
|
||||||
|
EXPOSE 2222
|
||||||
|
|
||||||
|
CMD ["sshecret-sshd", "run", "--host", "0.0.0.0"]
|
||||||
16
docker/admin.entrypoint.sh
Executable file
16
docker/admin.entrypoint.sh
Executable 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
|
||||||
15
docker/backend.entrypoint.sh
Executable file
15
docker/backend.entrypoint.sh
Executable file
@ -0,0 +1,15 @@
|
|||||||
|
#!/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."
|
||||||
|
[[ -d /opt/sshecret-backend-db ]] || mkdir /opt/sshecret-backend-db
|
||||||
|
|
||||||
|
export SSHECRET_BACKEND_DATABASE="/opt/sshecret-backend-db/sshecret.db"
|
||||||
|
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
sshecret-backend run --host 0.0.0.0
|
||||||
19
docker/docker-compose.yml
Normal file
19
docker/docker-compose.yml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: sshecret-backend
|
||||||
|
container_name: sshecret_backend
|
||||||
|
build:
|
||||||
|
context: ../
|
||||||
|
dockerfile: dockerfile.backend
|
||||||
|
networks:
|
||||||
|
- common
|
||||||
|
volumes:
|
||||||
|
- backend_data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backend_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
common:
|
||||||
119
packages/sshecret-admin/alembic.ini
Normal file
119
packages/sshecret-admin/alembic.ini
Normal 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
|
||||||
1
packages/sshecret-admin/migrations/README
Normal file
1
packages/sshecret-admin/migrations/README
Normal file
@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
105
packages/sshecret-admin/migrations/env.py
Normal file
105
packages/sshecret-admin/migrations/env.py
Normal 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()
|
||||||
28
packages/sshecret-admin/migrations/script.py.mako
Normal file
28
packages/sshecret-admin/migrations/script.py.mako
Normal 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"}
|
||||||
@ -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 ###
|
||||||
@ -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 ###
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
"""Implement db structures for internal password manager
|
||||||
|
|
||||||
|
Revision ID: 84356d0ea85f
|
||||||
|
Revises: 6c148590471f
|
||||||
|
Create Date: 2025-06-21 07:21:02.257865
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '84356d0ea85f'
|
||||||
|
down_revision: Union[str, None] = '6c148590471f'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('groups',
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('parent_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['parent_id'], ['groups.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('password_db', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('client_id', sa.Uuid(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('password_db', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('client_id')
|
||||||
|
|
||||||
|
op.drop_table('groups')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
"""Implement managed secrets
|
||||||
|
|
||||||
|
Revision ID: c34707a1ea3a
|
||||||
|
Revises: 84356d0ea85f
|
||||||
|
Create Date: 2025-06-21 07:38:12.994535
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'c34707a1ea3a'
|
||||||
|
down_revision: Union[str, None] = '84356d0ea85f'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('managed_secrets',
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=False),
|
||||||
|
sa.Column('is_deleted', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('group_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('groups', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('description', sa.String(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('groups', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('description')
|
||||||
|
|
||||||
|
op.drop_table('managed_secrets')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@ -8,21 +8,29 @@ authors = [
|
|||||||
]
|
]
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"alembic>=1.15.2",
|
||||||
|
"authlib>=1.6.0",
|
||||||
"bcrypt>=4.3.0",
|
"bcrypt>=4.3.0",
|
||||||
"click>=8.1.8",
|
"click>=8.1.8",
|
||||||
"cryptography>=44.0.2",
|
"cryptography>=44.0.2",
|
||||||
"fastapi[standard]>=0.115.12",
|
"fastapi[standard]>=0.115.12",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
"itsdangerous>=2.2.0",
|
||||||
"jinja2>=3.1.6",
|
"jinja2>=3.1.6",
|
||||||
"jinja2-fragments>=1.9.0",
|
"jinja2-fragments>=1.9.0",
|
||||||
|
"joserfc>=1.1.0",
|
||||||
"pydantic>=2.10.6",
|
"pydantic>=2.10.6",
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.10.1",
|
||||||
"pykeepass>=4.1.1.post1",
|
"pykeepass>=4.1.1.post1",
|
||||||
"sqlmodel>=0.0.24",
|
"sqlmodel>=0.0.24",
|
||||||
|
"sshecret",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
sshecret = { workspace = true }
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
sshecret-admin = "sshecret_admin.cli:cli"
|
sshecret-admin = "sshecret_admin.core.cli:cli"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
@ -31,4 +39,5 @@ build-backend = "hatchling.build"
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytailwindcss>=0.2.0",
|
"pytailwindcss>=0.2.0",
|
||||||
|
"types-pyjwt>=1.7.1",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,284 +0,0 @@
|
|||||||
"""Admin API."""
|
|
||||||
|
|
||||||
# pyright: reportUnusedFunction=false
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from collections import defaultdict
|
|
||||||
from datetime import timedelta
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from sshecret.backend import Client, SshecretBackend
|
|
||||||
from sshecret.backend.models import Secret
|
|
||||||
|
|
||||||
from .admin_backend import AdminBackend
|
|
||||||
from .auth_models import (
|
|
||||||
PasswordDB,
|
|
||||||
Token,
|
|
||||||
TokenData,
|
|
||||||
User,
|
|
||||||
create_access_token,
|
|
||||||
verify_password,
|
|
||||||
)
|
|
||||||
from .settings import AdminServerSettings
|
|
||||||
from .types import DBSessionDep
|
|
||||||
from .view_models import (
|
|
||||||
ClientCreate,
|
|
||||||
SecretCreate,
|
|
||||||
SecretUpdate,
|
|
||||||
SecretView,
|
|
||||||
UpdateKeyModel,
|
|
||||||
UpdateKeyResponse,
|
|
||||||
UpdatePoliciesRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
API_VERSION = "v1"
|
|
||||||
JWT_ALGORITHM = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate_user(session: Session, username: str, password: str) -> User | None:
|
|
||||||
"""Authenticate user."""
|
|
||||||
user = session.exec(select(User).where(User.username == username)).first()
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
if not verify_password(password, user.hashed_password):
|
|
||||||
return None
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def map_secrets_to_clients(
|
|
||||||
backend: SshecretBackend,
|
|
||||||
) -> defaultdict[str, list[str]]:
|
|
||||||
"""Map secrets to clients."""
|
|
||||||
clients = await backend.get_clients()
|
|
||||||
client_secret_map: defaultdict[str, list[str]] = defaultdict(list)
|
|
||||||
for client in clients:
|
|
||||||
for secret in client.secrets:
|
|
||||||
client_secret_map[secret].append(client.name)
|
|
||||||
return client_secret_map
|
|
||||||
|
|
||||||
|
|
||||||
def get_admin_api(
|
|
||||||
get_db_session: DBSessionDep, settings: AdminServerSettings
|
|
||||||
) -> APIRouter:
|
|
||||||
"""Get Admin API."""
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
|
||||||
|
|
||||||
async def get_admin_backend(session: Annotated[Session, Depends(get_db_session)]):
|
|
||||||
"""Get admin backend API."""
|
|
||||||
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
|
||||||
if not password_db:
|
|
||||||
raise HTTPException(
|
|
||||||
500, detail="Error: The password manager has not yet been set up."
|
|
||||||
)
|
|
||||||
admin = AdminBackend(settings, password_db.encrypted_password)
|
|
||||||
yield admin
|
|
||||||
|
|
||||||
async def get_current_user(
|
|
||||||
token: Annotated[str, Depends(oauth2_scheme)],
|
|
||||||
session: Annotated[Session, Depends(get_db_session)],
|
|
||||||
) -> User:
|
|
||||||
"""Get current user from token."""
|
|
||||||
credentials_exception = HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Could not validate credentials",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM])
|
|
||||||
username = payload.get("sub")
|
|
||||||
if not username:
|
|
||||||
raise credentials_exception
|
|
||||||
token_data = TokenData(username=username)
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
raise credentials_exception
|
|
||||||
|
|
||||||
user = session.exec(
|
|
||||||
select(User).where(User.username == token_data.username)
|
|
||||||
).first()
|
|
||||||
if not user:
|
|
||||||
raise credentials_exception
|
|
||||||
return user
|
|
||||||
|
|
||||||
async def get_current_active_user(
|
|
||||||
current_user: Annotated[User, Depends(get_current_user)],
|
|
||||||
) -> User:
|
|
||||||
"""Get current active user."""
|
|
||||||
if current_user.disabled:
|
|
||||||
raise HTTPException(status_code=400, detail="Inactive or disabled user")
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
app = APIRouter(
|
|
||||||
prefix=f"/api/{API_VERSION}", dependencies=[Depends(get_current_active_user)]
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.post("/token")
|
|
||||||
async def login_for_access_token(
|
|
||||||
session: Annotated[Session, Depends(get_db_session)],
|
|
||||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
|
||||||
) -> Token:
|
|
||||||
"""Login user and generate token."""
|
|
||||||
user = authenticate_user(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"},
|
|
||||||
)
|
|
||||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
||||||
access_token = create_access_token(
|
|
||||||
settings,
|
|
||||||
data={"sub": user.username},
|
|
||||||
expires_delta=access_token_expires,
|
|
||||||
)
|
|
||||||
return Token(access_token=access_token, token_type="bearer")
|
|
||||||
|
|
||||||
@app.get("/clients/")
|
|
||||||
async def get_clients(
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
|
||||||
) -> list[Client]:
|
|
||||||
"""Get clients."""
|
|
||||||
clients = await admin.get_clients()
|
|
||||||
return clients
|
|
||||||
|
|
||||||
@app.post("/clients/")
|
|
||||||
async def create_client(
|
|
||||||
new_client: ClientCreate,
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
) -> Client:
|
|
||||||
"""Create a new client."""
|
|
||||||
sources: list[str] | None = None
|
|
||||||
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
|
|
||||||
)
|
|
||||||
return client
|
|
||||||
|
|
||||||
@app.delete("/clients/{name}")
|
|
||||||
async def delete_client(
|
|
||||||
name: str, admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
|
||||||
) -> None:
|
|
||||||
"""Delete a client."""
|
|
||||||
await admin.delete_client(name)
|
|
||||||
|
|
||||||
@app.delete("/clients/{name}/secrets/{secret_name}")
|
|
||||||
async def delete_secret_from_client(
|
|
||||||
name: str,
|
|
||||||
secret_name: str,
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
) -> None:
|
|
||||||
"""Delete a secret from a client."""
|
|
||||||
client = await admin.get_client(name)
|
|
||||||
if not client:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
if secret_name not in client.secrets:
|
|
||||||
LOG.debug("Client does not have requested secret. No action to perform.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
await admin.delete_client_secret(name, secret_name)
|
|
||||||
|
|
||||||
@app.put("/clients/{name}/policies")
|
|
||||||
async def update_client_policies(
|
|
||||||
name: str,
|
|
||||||
updated: UpdatePoliciesRequest,
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
) -> Client:
|
|
||||||
"""Update the client access policies."""
|
|
||||||
client = await admin.get_client(name)
|
|
||||||
if not client:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
assert client is not None, "Critical: The client disappeared after update!"
|
|
||||||
|
|
||||||
return client
|
|
||||||
|
|
||||||
@app.get("/secrets/")
|
|
||||||
async def get_secret_names(
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
|
||||||
) -> list[Secret]:
|
|
||||||
"""Get Secret Names."""
|
|
||||||
return await admin.get_secrets()
|
|
||||||
|
|
||||||
@app.post("/secrets/")
|
|
||||||
async def add_secret(
|
|
||||||
secret: SecretCreate, admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
|
||||||
) -> None:
|
|
||||||
"""Create a secret."""
|
|
||||||
await admin.add_secret(secret.name, secret.get_secret(), secret.clients)
|
|
||||||
|
|
||||||
@app.get("/secrets/{name}")
|
|
||||||
async def get_secret(
|
|
||||||
name: str, admin: Annotated[AdminBackend, Depends(get_admin_backend)]
|
|
||||||
) -> SecretView:
|
|
||||||
"""Get a secret."""
|
|
||||||
secret_view = await admin.get_secret(name)
|
|
||||||
|
|
||||||
if not secret_view:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found."
|
|
||||||
)
|
|
||||||
return secret_view
|
|
||||||
|
|
||||||
@app.put("/secrets/{name}")
|
|
||||||
async def update_secret(
|
|
||||||
name: str,
|
|
||||||
value: SecretUpdate,
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
) -> None:
|
|
||||||
new_value = value.get_secret()
|
|
||||||
await admin.update_secret(name, new_value)
|
|
||||||
|
|
||||||
@app.delete("/secrets/{name}")
|
|
||||||
async def delete_secret(
|
|
||||||
name: str,
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
) -> None:
|
|
||||||
"""Delete secret."""
|
|
||||||
await admin.delete_secret(name)
|
|
||||||
|
|
||||||
@app.put("/clients/{name}/public-key")
|
|
||||||
async def update_client_public_key(
|
|
||||||
name: str,
|
|
||||||
updated: UpdateKeyModel,
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
) -> UpdateKeyResponse:
|
|
||||||
"""Update client public key.
|
|
||||||
|
|
||||||
Updating the public key will invalidate the current secrets, so these well
|
|
||||||
be resolved first, and re-encrypted using the new key.
|
|
||||||
"""
|
|
||||||
# Let's first ensure that the key is actually updated.
|
|
||||||
updated_secrets = await admin.update_client_public_key(name, updated.public_key)
|
|
||||||
return UpdateKeyResponse(
|
|
||||||
public_key=updated.public_key, updated_secrets=updated_secrets
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.put("/clients/{name}/secrets/{secret_name}")
|
|
||||||
async def add_secret_to_client(
|
|
||||||
name: str,
|
|
||||||
secret_name: str,
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
) -> None:
|
|
||||||
"""Add secret to a client."""
|
|
||||||
await admin.create_client_secret(name, secret_name)
|
|
||||||
|
|
||||||
return app
|
|
||||||
@ -1,402 +0,0 @@
|
|||||||
"""API for working with the clients.
|
|
||||||
|
|
||||||
Since we have a frontend and a REST API, it makes sense to have a generic library to work with the clients.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
from sshecret.backend import AuditLog, Client, ClientFilter, Secret, SshecretBackend
|
|
||||||
from sshecret.backend.models import DetailedSecrets
|
|
||||||
from sshecret.crypto import encrypt_string, load_public_key
|
|
||||||
|
|
||||||
from .keepass import PasswordContext, load_password_manager
|
|
||||||
from .settings import AdminServerSettings
|
|
||||||
from .view_models import SecretView
|
|
||||||
|
|
||||||
|
|
||||||
class ClientManagementError(Exception):
|
|
||||||
"""Base exception for client management operations."""
|
|
||||||
|
|
||||||
|
|
||||||
class ClientNotFoundError(ClientManagementError):
|
|
||||||
"""Client not found."""
|
|
||||||
|
|
||||||
|
|
||||||
class SecretNotFoundError(ClientManagementError):
|
|
||||||
"""Secret not found."""
|
|
||||||
|
|
||||||
|
|
||||||
class BackendUnavailableError(ClientManagementError):
|
|
||||||
"""Backend unavailable."""
|
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class AdminBackend:
|
|
||||||
"""Admin backend API."""
|
|
||||||
|
|
||||||
def __init__(self, settings: AdminServerSettings, keepass_password: str) -> None:
|
|
||||||
"""Create client management API."""
|
|
||||||
self.settings: AdminServerSettings = settings
|
|
||||||
self.backend: SshecretBackend = SshecretBackend(
|
|
||||||
str(settings.backend_url), settings.backend_token
|
|
||||||
)
|
|
||||||
self.keepass_password: str = keepass_password
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def password_manager(self) -> Iterator[PasswordContext]:
|
|
||||||
"""Open the password manager."""
|
|
||||||
with load_password_manager(self.settings, self.keepass_password) as kp:
|
|
||||||
yield kp
|
|
||||||
|
|
||||||
async def _get_clients(self, filter: ClientFilter | None = None) -> list[Client]:
|
|
||||||
"""Get clients from backend."""
|
|
||||||
return await self.backend.get_clients(filter)
|
|
||||||
|
|
||||||
async def get_clients(self, filter: ClientFilter | None = None) -> list[Client]:
|
|
||||||
"""Get clients from backend."""
|
|
||||||
try:
|
|
||||||
return await self._get_clients(filter)
|
|
||||||
except ClientManagementError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def _get_client(self, name: str) -> Client | None:
|
|
||||||
"""Get a client from the backend."""
|
|
||||||
return await self.backend.get_client(name)
|
|
||||||
|
|
||||||
async def _verify_client_exists(self, name: str) -> None:
|
|
||||||
"""Check that a client exists."""
|
|
||||||
client = await self.backend.get_client(name)
|
|
||||||
if not client:
|
|
||||||
raise ClientNotFoundError()
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def verify_client_exists(self, name: str) -> None:
|
|
||||||
"""Check that a client exists."""
|
|
||||||
try:
|
|
||||||
await self._verify_client_exists(name)
|
|
||||||
except ClientManagementError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def get_client(self, name: str) -> Client | None:
|
|
||||||
"""Get a client from the backend."""
|
|
||||||
try:
|
|
||||||
return await self._get_client(name)
|
|
||||||
except ClientManagementError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def _create_client(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
public_key: str,
|
|
||||||
description: str | None = None,
|
|
||||||
sources: list[str] | None = None,
|
|
||||||
) -> Client:
|
|
||||||
"""Create client."""
|
|
||||||
await self.backend.create_client(name, public_key, description)
|
|
||||||
if sources:
|
|
||||||
await self.backend.update_client_sources(name, sources)
|
|
||||||
client = await self.get_client(name)
|
|
||||||
|
|
||||||
if not client:
|
|
||||||
raise ClientNotFoundError()
|
|
||||||
|
|
||||||
return client
|
|
||||||
|
|
||||||
async def create_client(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
public_key: str,
|
|
||||||
description: str | None = None,
|
|
||||||
sources: list[str] | None = None,
|
|
||||||
) -> Client:
|
|
||||||
"""Create client."""
|
|
||||||
try:
|
|
||||||
return await self._create_client(name, public_key, description, sources)
|
|
||||||
except ClientManagementError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def _update_client_public_key(
|
|
||||||
self, name: str, new_key: str, password_manager: PasswordContext
|
|
||||||
) -> list[str]:
|
|
||||||
"""Update client public key."""
|
|
||||||
LOG.info(
|
|
||||||
"Updating client %s public key. This will invalidate all existing secrets."
|
|
||||||
)
|
|
||||||
client = await self.get_client(name)
|
|
||||||
if not client:
|
|
||||||
raise ClientNotFoundError()
|
|
||||||
await self.backend.update_client_key(name, new_key)
|
|
||||||
updated_secrets: list[str] = []
|
|
||||||
for secret in client.secrets:
|
|
||||||
LOG.debug("Re-encrypting secret %s for client %s", secret, name)
|
|
||||||
secret_value = password_manager.get_secret(secret)
|
|
||||||
if not secret_value:
|
|
||||||
LOG.warning(
|
|
||||||
"Referenced secret %s does not exist! Skipping.", secret_value
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
rsa_public_key = load_public_key(client.public_key.encode())
|
|
||||||
encrypted = encrypt_string(secret_value, rsa_public_key)
|
|
||||||
LOG.debug("Sending new encrypted value to backend.")
|
|
||||||
await self.backend.create_client_secret(name, secret, encrypted)
|
|
||||||
updated_secrets.append(secret)
|
|
||||||
|
|
||||||
return updated_secrets
|
|
||||||
|
|
||||||
async def update_client_public_key(self, name: str, new_key: str) -> list[str]:
|
|
||||||
"""Update client public key."""
|
|
||||||
try:
|
|
||||||
with self.password_manager() as password_manager:
|
|
||||||
return await self._update_client_public_key(
|
|
||||||
name, new_key, password_manager
|
|
||||||
)
|
|
||||||
except ClientManagementError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def _update_client(self, new_client: Client) -> Client:
|
|
||||||
"""Update a client object."""
|
|
||||||
existing_client = await self.get_client(new_client.name)
|
|
||||||
if not existing_client:
|
|
||||||
raise ClientNotFoundError()
|
|
||||||
await self.backend.update_client(new_client)
|
|
||||||
if new_client.public_key != existing_client.public_key:
|
|
||||||
await self.update_client_public_key(new_client.name, new_client.public_key)
|
|
||||||
|
|
||||||
updated_client = await self.get_client(new_client.name)
|
|
||||||
if not updated_client:
|
|
||||||
raise ClientNotFoundError()
|
|
||||||
return updated_client
|
|
||||||
|
|
||||||
async def update_client(self, new_client: Client) -> Client:
|
|
||||||
"""Update a client object."""
|
|
||||||
try:
|
|
||||||
return await self._update_client(new_client)
|
|
||||||
except ClientManagementError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def update_client_sources(self, name: str, sources: list[str]) -> None:
|
|
||||||
"""Update client sources."""
|
|
||||||
try:
|
|
||||||
await self.backend.update_client_sources(name, sources)
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def _delete_client(self, name: str) -> None:
|
|
||||||
"""Delete client."""
|
|
||||||
await self.backend.delete_client(name)
|
|
||||||
|
|
||||||
async def delete_client(self, name: str) -> None:
|
|
||||||
"""Delete client."""
|
|
||||||
try:
|
|
||||||
await self._delete_client(name)
|
|
||||||
except ClientManagementError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def delete_client_secret(self, client_name: str, secret_name: str) -> None:
|
|
||||||
"""Delete a secret from a client."""
|
|
||||||
try:
|
|
||||||
await self.backend.delete_client_secret(client_name, secret_name)
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def _get_secrets(self) -> list[Secret]:
|
|
||||||
"""Get secrets.
|
|
||||||
|
|
||||||
This fetches the secret to client mapping from backend, and adds secrets from the password manager.
|
|
||||||
"""
|
|
||||||
with self.password_manager() as password_manager:
|
|
||||||
all_secrets = password_manager.get_available_secrets()
|
|
||||||
|
|
||||||
secrets = await self.backend.get_secrets()
|
|
||||||
backend_secret_names = [secret.name for secret in secrets]
|
|
||||||
for secret in all_secrets:
|
|
||||||
if secret not in backend_secret_names:
|
|
||||||
secrets.append(Secret(name=secret, clients=[]))
|
|
||||||
|
|
||||||
return secrets
|
|
||||||
|
|
||||||
async def get_secrets(self) -> list[Secret]:
|
|
||||||
"""Get secrets from backend."""
|
|
||||||
try:
|
|
||||||
return await self._get_secrets()
|
|
||||||
except ClientManagementError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def _get_detailed_secrets(self) -> list[DetailedSecrets]:
|
|
||||||
"""Get detailed secrets.
|
|
||||||
|
|
||||||
This fetches the secret to client mapping from backend, and adds secrets from the password manager.
|
|
||||||
"""
|
|
||||||
with self.password_manager() as password_manager:
|
|
||||||
all_secrets = password_manager.get_available_secrets()
|
|
||||||
|
|
||||||
secrets = await self.backend.get_detailed_secrets()
|
|
||||||
backend_secret_names = [secret.name for secret in secrets]
|
|
||||||
for secret in all_secrets:
|
|
||||||
if secret not in backend_secret_names:
|
|
||||||
secrets.append(DetailedSecrets(name=secret, ids=[], clients=[]))
|
|
||||||
|
|
||||||
return secrets
|
|
||||||
|
|
||||||
async def get_detailed_secrets(self) -> list[DetailedSecrets]:
|
|
||||||
"""Get detailed secrets from backend."""
|
|
||||||
try:
|
|
||||||
return await self._get_detailed_secrets()
|
|
||||||
except ClientManagementError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def get_secret(self, name: str) -> SecretView | None:
|
|
||||||
"""Get secrets from backend."""
|
|
||||||
try:
|
|
||||||
return await self._get_secret(name)
|
|
||||||
except ClientManagementError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def _get_secret(self, name: str) -> SecretView | None:
|
|
||||||
"""Get a secret, including the actual unencrypted value and clients."""
|
|
||||||
with self.password_manager() as password_manager:
|
|
||||||
secret = password_manager.get_secret(name)
|
|
||||||
|
|
||||||
if not secret:
|
|
||||||
return None
|
|
||||||
secret_view = SecretView(name=name, secret=secret)
|
|
||||||
secret_mapping = await self.backend.get_secret(name)
|
|
||||||
if secret_mapping:
|
|
||||||
secret_view.clients = secret_mapping.clients
|
|
||||||
|
|
||||||
return secret_view
|
|
||||||
|
|
||||||
async def delete_secret(self, name: str) -> None:
|
|
||||||
"""Delete a secret."""
|
|
||||||
try:
|
|
||||||
return await self._delete_secret(name)
|
|
||||||
except ClientManagementError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def _delete_secret(self, name: str) -> None:
|
|
||||||
"""Delete a secret."""
|
|
||||||
with self.password_manager() as password_manager:
|
|
||||||
password_manager.delete_entry(name)
|
|
||||||
|
|
||||||
secret_mapping = await self.backend.get_secret(name)
|
|
||||||
if not secret_mapping:
|
|
||||||
return
|
|
||||||
for client in secret_mapping.clients:
|
|
||||||
LOG.info("Deleting secret %s from client %s", name, client)
|
|
||||||
await self.backend.delete_client_secret(client, name)
|
|
||||||
|
|
||||||
async def _add_secret(
|
|
||||||
self, name: str, value: str, clients: list[str] | None, update: bool = False
|
|
||||||
) -> None:
|
|
||||||
"""Add a secret."""
|
|
||||||
with self.password_manager() as password_manager:
|
|
||||||
password_manager.add_entry(name, value, update)
|
|
||||||
|
|
||||||
if update:
|
|
||||||
secret_map = await self.backend.get_secret(name)
|
|
||||||
if secret_map:
|
|
||||||
clients = secret_map.clients
|
|
||||||
|
|
||||||
if not clients:
|
|
||||||
return
|
|
||||||
for client_name in clients:
|
|
||||||
client = await self.get_client(client_name)
|
|
||||||
if not client:
|
|
||||||
if update:
|
|
||||||
raise ClientNotFoundError()
|
|
||||||
LOG.warning("Requested client %s not found!", client_name)
|
|
||||||
continue
|
|
||||||
public_key = load_public_key(client.public_key.encode())
|
|
||||||
encrypted = encrypt_string(value, public_key)
|
|
||||||
LOG.info("Wrote encrypted secret for client %s", client_name)
|
|
||||||
await self.backend.create_client_secret(client_name, name, encrypted)
|
|
||||||
|
|
||||||
async def add_secret(
|
|
||||||
self, name: str, value: str, clients: list[str] | None = None
|
|
||||||
) -> None:
|
|
||||||
"""Add a secret."""
|
|
||||||
try:
|
|
||||||
await self._add_secret(name, value, clients)
|
|
||||||
except ClientManagementError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def update_secret(self, name: str, value: str) -> None:
|
|
||||||
"""Update secrets."""
|
|
||||||
try:
|
|
||||||
await self._add_secret(name, value, None, True)
|
|
||||||
except ClientManagementError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def _create_client_secret(self, client_name: str, secret_name: str) -> None:
|
|
||||||
"""Create client secret."""
|
|
||||||
client = await self.get_client(client_name)
|
|
||||||
if not client:
|
|
||||||
raise ClientNotFoundError()
|
|
||||||
|
|
||||||
with self.password_manager() as password_manager:
|
|
||||||
secret = password_manager.get_secret(secret_name)
|
|
||||||
if not secret:
|
|
||||||
raise SecretNotFoundError()
|
|
||||||
|
|
||||||
public_key = load_public_key(client.public_key.encode())
|
|
||||||
encrypted = encrypt_string(secret, public_key)
|
|
||||||
await self.backend.create_client_secret(client_name, secret_name, encrypted)
|
|
||||||
|
|
||||||
async def create_client_secret(self, client_name: str, secret_name: str) -> None:
|
|
||||||
"""Create client secret."""
|
|
||||||
try:
|
|
||||||
await self._create_client_secret(client_name, secret_name)
|
|
||||||
except ClientManagementError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise BackendUnavailableError() from e
|
|
||||||
|
|
||||||
async def get_audit_log(
|
|
||||||
self,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
client_name: str | None = None,
|
|
||||||
subsystem: str | None = None,
|
|
||||||
) -> list[AuditLog]:
|
|
||||||
"""Get audit log from backend."""
|
|
||||||
return await self.backend.get_audit_log(offset, limit, client_name, subsystem)
|
|
||||||
|
|
||||||
async def write_audit_log(self, entry: AuditLog) -> None:
|
|
||||||
"""Write to the audit log."""
|
|
||||||
if not entry.subsystem:
|
|
||||||
entry.subsystem = "admin"
|
|
||||||
await self.backend.add_audit_log(entry)
|
|
||||||
|
|
||||||
async def get_audit_log_count(self) -> int:
|
|
||||||
"""Get audit log count."""
|
|
||||||
return await self.backend.get_audit_log_count()
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
"""Admin REST API."""
|
||||||
|
|
||||||
|
from .router import create_router as create_api_router
|
||||||
|
|
||||||
|
__all__ = ["create_api_router"]
|
||||||
@ -0,0 +1 @@
|
|||||||
|
"""API Endpoints."""
|
||||||
@ -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
|
||||||
227
packages/sshecret-admin/src/sshecret_admin/api/endpoints/auth.py
Normal file
227
packages/sshecret-admin/src/sshecret_admin/api/endpoints/auth.py
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
"""Authentication related endpoints factory."""
|
||||||
|
|
||||||
|
# pyright: reportUnusedFunction=false
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
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 pydantic import BaseModel, ValidationError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
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[AsyncSession, Depends(dependencies.get_async_session)],
|
||||||
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||||
|
) -> Token:
|
||||||
|
"""Login user and generate token."""
|
||||||
|
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=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("/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
|
||||||
@ -0,0 +1,220 @@
|
|||||||
|
"""Client-related endpoints factory."""
|
||||||
|
|
||||||
|
# pyright: reportUnusedFunction=false
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
|
||||||
|
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."""
|
||||||
|
app = APIRouter(dependencies=[Depends(dependencies.get_current_active_user)])
|
||||||
|
|
||||||
|
@app.get("/clients/")
|
||||||
|
async def get_clients(
|
||||||
|
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,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> Client:
|
||||||
|
"""Create a new client."""
|
||||||
|
sources: list[str] | None = None
|
||||||
|
if new_client.sources:
|
||||||
|
sources = [str(source) for source in new_client.sources]
|
||||||
|
client = await admin.create_client(
|
||||||
|
name=new_client.name,
|
||||||
|
public_key=new_client.public_key,
|
||||||
|
description=new_client.description,
|
||||||
|
sources=sources,
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
@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(
|
||||||
|
id: ClientIdParam,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> None:
|
||||||
|
"""Delete a client."""
|
||||||
|
await admin.delete_client(_id(id))
|
||||||
|
|
||||||
|
@app.delete("/clients/{id}/secrets/{secret_name}")
|
||||||
|
async def delete_secret_from_client(
|
||||||
|
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(_id(id))
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if secret_name not in client.secrets:
|
||||||
|
LOG.debug("Client does not have requested secret. No action to perform.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
await admin.delete_client_secret(("id", id), secret_name)
|
||||||
|
|
||||||
|
@app.put("/clients/{id}/policies")
|
||||||
|
async def update_client_policies(
|
||||||
|
id: str,
|
||||||
|
updated: UpdatePoliciesRequest,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> Client:
|
||||||
|
"""Update the client access policies."""
|
||||||
|
client = await admin.get_client(_id(id))
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
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(("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/{id}/public-key")
|
||||||
|
async def update_client_public_key(
|
||||||
|
id: ClientIdParam,
|
||||||
|
updated: UpdateKeyModel,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> UpdateKeyResponse:
|
||||||
|
"""Update client public key.
|
||||||
|
|
||||||
|
Updating the public key will invalidate the current secrets, so these well
|
||||||
|
be resolved first, and re-encrypted using the new key.
|
||||||
|
"""
|
||||||
|
# Let's first ensure that the key is actually updated.
|
||||||
|
updated_secrets = await admin.update_client_public_key(
|
||||||
|
_id(id), updated.public_key
|
||||||
|
)
|
||||||
|
return UpdateKeyResponse(
|
||||||
|
public_key=updated.public_key, updated_secrets=updated_secrets
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.put("/clients/{id}/secrets/{secret_name}")
|
||||||
|
async def add_secret_to_client(
|
||||||
|
id: ClientIdParam,
|
||||||
|
secret_name: str,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> None:
|
||||||
|
"""Add secret to a client."""
|
||||||
|
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
|
||||||
@ -0,0 +1,231 @@
|
|||||||
|
"""Secrets related endpoints factory."""
|
||||||
|
|
||||||
|
# pyright: reportUnusedFunction=false
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||||
|
|
||||||
|
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=[Security(dependencies.get_current_active_user)])
|
||||||
|
|
||||||
|
@app.get("/secrets/")
|
||||||
|
async def get_secret_names(
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> list[SecretListView]:
|
||||||
|
"""Get Secret Names."""
|
||||||
|
return await admin.get_secrets()
|
||||||
|
|
||||||
|
@app.post("/secrets/")
|
||||||
|
async def add_secret(
|
||||||
|
secret: SecretCreate,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> None:
|
||||||
|
"""Create a secret."""
|
||||||
|
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(
|
||||||
|
name: str,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> SecretView:
|
||||||
|
"""Get a secret."""
|
||||||
|
secret_view = await admin.get_secret(name)
|
||||||
|
|
||||||
|
if not secret_view:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Item not found."
|
||||||
|
)
|
||||||
|
return secret_view
|
||||||
|
|
||||||
|
@app.put("/secrets/{name}")
|
||||||
|
async def update_secret(
|
||||||
|
name: str,
|
||||||
|
value: SecretUpdate,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> None:
|
||||||
|
new_value = value.get_secret()
|
||||||
|
await admin.update_secret(name, new_value)
|
||||||
|
|
||||||
|
@app.delete("/secrets/{name}")
|
||||||
|
async def delete_secret(
|
||||||
|
name: str,
|
||||||
|
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
|
||||||
|
) -> None:
|
||||||
|
"""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
|
||||||
120
packages/sshecret-admin/src/sshecret_admin/api/router.py
Normal file
120
packages/sshecret-admin/src/sshecret_admin/api/router.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""Main API Router."""
|
||||||
|
|
||||||
|
# pyright: reportUnusedFunction=false
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from fastapi.security.utils import get_authorization_scheme_param
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from sshecret_admin.services.admin_backend import AdminBackend
|
||||||
|
from sshecret_admin.core.dependencies import BaseDependencies, AdminDependencies
|
||||||
|
from sshecret_admin.auth import User, decode_token
|
||||||
|
from sshecret_admin.auth.constants import LOCAL_ISSUER
|
||||||
|
|
||||||
|
from .endpoints import audit, auth, clients, secrets
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
API_VERSION = "v1"
|
||||||
|
|
||||||
|
|
||||||
|
def create_router(dependencies: BaseDependencies) -> APIRouter:
|
||||||
|
"""Create clients router."""
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token", refreshUrl="/api/v1/refresh")
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
token: Annotated[str, Depends(oauth2_scheme)],
|
||||||
|
session: Annotated[Session, Depends(dependencies.get_db_session)],
|
||||||
|
) -> User:
|
||||||
|
"""Get current user from token."""
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
token_data = decode_token(dependencies.settings, token)
|
||||||
|
if not token_data:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Get current active user."""
|
||||||
|
if current_user.disabled:
|
||||||
|
raise HTTPException(status_code=400, detail="Inactive or disabled user")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
async def get_admin_backend(
|
||||||
|
request: Request,
|
||||||
|
):
|
||||||
|
"""Get admin backend API."""
|
||||||
|
username = get_optional_username(request)
|
||||||
|
origin = get_client_origin(request)
|
||||||
|
admin = AdminBackend(
|
||||||
|
dependencies.settings,
|
||||||
|
username=username,
|
||||||
|
origin=origin,
|
||||||
|
)
|
||||||
|
yield admin
|
||||||
|
|
||||||
|
app = APIRouter(prefix=f"/api/{API_VERSION}")
|
||||||
|
|
||||||
|
endpoint_deps = AdminDependencies.create(
|
||||||
|
dependencies, get_admin_backend, get_current_active_user
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
return app
|
||||||
@ -1,118 +0,0 @@
|
|||||||
"""FastAPI app."""
|
|
||||||
|
|
||||||
# pyright: reportUnusedFunction=false
|
|
||||||
#
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, status
|
|
||||||
from fastapi.encoders import jsonable_encoder
|
|
||||||
from fastapi.exceptions import RequestValidationError
|
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
from .admin_api import get_admin_api
|
|
||||||
from .auth_models import init_db, PasswordDB, AuthenticationFailedError, AuthenticationNeededError
|
|
||||||
from .db import setup_database
|
|
||||||
from .master_password import setup_master_password
|
|
||||||
from .settings import AdminServerSettings
|
|
||||||
from .frontend import create_frontend
|
|
||||||
from .types import DBSessionDep
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# dir_path = os.path.dirname(os.path.realpath(__file__))
|
|
||||||
|
|
||||||
|
|
||||||
def setup_frontend(
|
|
||||||
app: FastAPI, settings: AdminServerSettings, get_db_session: DBSessionDep
|
|
||||||
) -> None:
|
|
||||||
"""Setup frontend."""
|
|
||||||
script_path = Path(os.path.dirname(os.path.realpath(__file__)))
|
|
||||||
static_path = script_path / "static"
|
|
||||||
|
|
||||||
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
|
||||||
frontend = create_frontend(settings, get_db_session)
|
|
||||||
app.include_router(frontend)
|
|
||||||
|
|
||||||
|
|
||||||
def create_admin_app(
|
|
||||||
settings: AdminServerSettings, with_frontend: bool = True
|
|
||||||
) -> FastAPI:
|
|
||||||
"""Create admin app."""
|
|
||||||
engine, get_db_session = setup_database(settings.admin_db)
|
|
||||||
|
|
||||||
def setup_password_manager() -> None:
|
|
||||||
"""Setup password manager."""
|
|
||||||
encr_master_password = setup_master_password(
|
|
||||||
settings=settings, regenerate=False
|
|
||||||
)
|
|
||||||
with Session(engine) as session:
|
|
||||||
existing_password = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
|
||||||
|
|
||||||
if not encr_master_password:
|
|
||||||
if existing_password:
|
|
||||||
LOG.info("Master password already defined.")
|
|
||||||
return
|
|
||||||
# Looks like we have to regenerate it
|
|
||||||
LOG.warning("Master password was set, but not saved to the database. Regenerating it.")
|
|
||||||
encr_master_password = setup_master_password(settings=settings, regenerate=True)
|
|
||||||
|
|
||||||
assert encr_master_password is not None
|
|
||||||
|
|
||||||
with Session(engine) as session:
|
|
||||||
pwdb = PasswordDB(id=1, encrypted_password=encr_master_password)
|
|
||||||
session.add(pwdb)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(_app: FastAPI):
|
|
||||||
"""Create database before starting the server."""
|
|
||||||
init_db(engine)
|
|
||||||
setup_password_manager()
|
|
||||||
yield
|
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
|
||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
|
||||||
async def validation_exception_handler(
|
|
||||||
request: Request, exc: RequestValidationError
|
|
||||||
):
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.exception_handler(AuthenticationNeededError)
|
|
||||||
async def authentication_needed_handler(
|
|
||||||
request: Request, exc: AuthenticationNeededError,
|
|
||||||
):
|
|
||||||
qs = f"error_title={exc.login_error.title}&error_message={exc.login_error.message}"
|
|
||||||
return RedirectResponse(f"/?{qs}")
|
|
||||||
|
|
||||||
@app.exception_handler(AuthenticationFailedError)
|
|
||||||
async def authentication_failed_handler(
|
|
||||||
request: Request, exc: AuthenticationNeededError,
|
|
||||||
):
|
|
||||||
qs = f"error_title={exc.login_error.title}&error_message={exc.login_error.message}"
|
|
||||||
return RedirectResponse(f"/?{qs}")
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def get_health() -> JSONResponse:
|
|
||||||
"""Provide simple health check."""
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=status.HTTP_200_OK, content=jsonable_encoder({"status": "LIVE"})
|
|
||||||
)
|
|
||||||
|
|
||||||
admin_api = get_admin_api(get_db_session, settings)
|
|
||||||
|
|
||||||
app.include_router(admin_api)
|
|
||||||
if with_frontend:
|
|
||||||
setup_frontend(app, settings, get_db_session)
|
|
||||||
|
|
||||||
return app
|
|
||||||
28
packages/sshecret-admin/src/sshecret_admin/auth/__init__.py
Normal file
28
packages/sshecret-admin/src/sshecret_admin/auth/__init__.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"""Authentication related module."""
|
||||||
|
|
||||||
|
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, IdentityClaims, LocalUserInfo
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"IdentityClaims",
|
||||||
|
"LocalUserInfo",
|
||||||
|
"PasswordDB",
|
||||||
|
"Token",
|
||||||
|
"User",
|
||||||
|
"authenticate_user",
|
||||||
|
"authenticate_user_async",
|
||||||
|
"check_password",
|
||||||
|
"create_access_token",
|
||||||
|
"create_refresh_token",
|
||||||
|
"decode_token",
|
||||||
|
"verify_password",
|
||||||
|
]
|
||||||
@ -0,0 +1,191 @@
|
|||||||
|
"""Authentication utilities."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import cast, Any
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
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 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__)
|
||||||
|
|
||||||
|
|
||||||
|
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, "iss": provider})
|
||||||
|
key = OctKey.import_key(settings.secret_key)
|
||||||
|
encoded_jwt = jwt.encode({"alg": JWT_ALGORITHM}, to_encode, key)
|
||||||
|
return str(encoded_jwt)
|
||||||
|
|
||||||
|
|
||||||
|
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, 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, provider)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify password against stored hash."""
|
||||||
|
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def check_password(plain_password: str, hashed_password: str) -> None:
|
||||||
|
"""Check password.
|
||||||
|
|
||||||
|
If password doesn't match, throw AuthenticationFailedError.
|
||||||
|
"""
|
||||||
|
if not verify_password(plain_password, hashed_password):
|
||||||
|
raise AuthenticationFailedError()
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
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])
|
||||||
|
sub = cast("str | None", payload.claims.get("sub"))
|
||||||
|
if not sub:
|
||||||
|
return None
|
||||||
|
|
||||||
|
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)
|
||||||
@ -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"
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
"""Authentication related exceptions."""
|
||||||
|
|
||||||
|
from typing import override
|
||||||
|
|
||||||
|
from .models import LoginError
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationFailedError(Exception):
|
||||||
|
"""Authentication failed."""
|
||||||
|
|
||||||
|
@override
|
||||||
|
def __init__(self, message: str | None = None) -> None:
|
||||||
|
"""Initialize exception class."""
|
||||||
|
if not message:
|
||||||
|
message = "Invalid user or password."
|
||||||
|
super().__init__(message)
|
||||||
|
self.login_error: LoginError = LoginError(
|
||||||
|
title="Authentication Failed", message=message
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationNeededError(Exception):
|
||||||
|
"""Authentication needed error."""
|
||||||
|
|
||||||
|
@override
|
||||||
|
def __init__(self, message: str | None = None) -> None:
|
||||||
|
"""Initialize exception class."""
|
||||||
|
if not message:
|
||||||
|
message = "You need to be logged in to continue."
|
||||||
|
super().__init__(message)
|
||||||
|
self.login_error: LoginError = LoginError(title="Unauthorized", message=message)
|
||||||
196
packages/sshecret-admin/src/sshecret_admin/auth/models.py
Normal file
196
packages/sshecret-admin/src/sshecret_admin/auth/models.py
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
"""Models for authentication and secret management."""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import override
|
||||||
|
import uuid
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class AuthProvider(enum.Enum):
|
||||||
|
"""Auth providers."""
|
||||||
|
|
||||||
|
LOCAL = "local"
|
||||||
|
OIDC = "oidc"
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""Users."""
|
||||||
|
|
||||||
|
__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(Base):
|
||||||
|
"""Password database."""
|
||||||
|
|
||||||
|
__tablename__: str = "password_db"
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
__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(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginError(BaseModel):
|
||||||
|
"""Login Error model."""
|
||||||
|
|
||||||
|
# TODO: Remove this.
|
||||||
|
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
78
packages/sshecret-admin/src/sshecret_admin/auth/oidc.py
Normal file
78
packages/sshecret-admin/src/sshecret_admin/auth/oidc.py
Normal 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,
|
||||||
|
)
|
||||||
@ -1,125 +0,0 @@
|
|||||||
"""Models for authentication."""
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
import bcrypt
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from typing import Any, override
|
|
||||||
import jwt
|
|
||||||
from sqlmodel import SQLModel, Field
|
|
||||||
from sshecret_admin.settings import AdminServerSettings
|
|
||||||
|
|
||||||
|
|
||||||
JWT_ALGORITHM = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
|
||||||
|
|
||||||
|
|
||||||
class User(SQLModel, table=True):
|
|
||||||
"""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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordDB(SQLModel, table=True):
|
|
||||||
"""Password database."""
|
|
||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
|
||||||
encrypted_password: str
|
|
||||||
|
|
||||||
created_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_type=sa.DateTime(timezone=True),
|
|
||||||
sa_column_kwargs={"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()},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def init_db(engine: sa.Engine) -> None:
|
|
||||||
"""Create database."""
|
|
||||||
SQLModel.metadata.create_all(engine)
|
|
||||||
|
|
||||||
|
|
||||||
class TokenData(SQLModel):
|
|
||||||
"""Token data."""
|
|
||||||
|
|
||||||
username: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Token(SQLModel):
|
|
||||||
access_token: str
|
|
||||||
token_type: str
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(
|
|
||||||
settings: AdminServerSettings,
|
|
||||||
data: dict[str, Any],
|
|
||||||
expires_delta: timedelta | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Create access token."""
|
|
||||||
to_encode = data.copy()
|
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
|
||||||
if expires_delta:
|
|
||||||
expire = datetime.now(timezone.utc) + expires_delta
|
|
||||||
to_encode.update({"exp": expire})
|
|
||||||
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=JWT_ALGORITHM)
|
|
||||||
return encoded_jwt
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
||||||
"""Verify password against stored hash."""
|
|
||||||
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
|
||||||
|
|
||||||
|
|
||||||
def check_password(plain_password: str, hashed_password: str) -> None:
|
|
||||||
"""Check password.
|
|
||||||
|
|
||||||
If password doesn't match, throw AuthenticationFailedError.
|
|
||||||
"""
|
|
||||||
if not verify_password(plain_password, hashed_password):
|
|
||||||
raise AuthenticationFailedError()
|
|
||||||
|
|
||||||
|
|
||||||
class LoginError(SQLModel):
|
|
||||||
"""Login Error model."""
|
|
||||||
|
|
||||||
title: str
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationFailedError(Exception):
|
|
||||||
"""Authentication failed."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
def __init__(self, message: str | None = None) -> None:
|
|
||||||
"""Initialize exception class."""
|
|
||||||
if not message:
|
|
||||||
message = "Invalid user or password."
|
|
||||||
super().__init__(message)
|
|
||||||
self.login_error: LoginError = LoginError(
|
|
||||||
title="Authentication Failed", message=message
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticationNeededError(Exception):
|
|
||||||
"""Authentication needed error."""
|
|
||||||
|
|
||||||
@override
|
|
||||||
def __init__(self, message: str | None = None) -> None:
|
|
||||||
"""Initialize exception class."""
|
|
||||||
if not message:
|
|
||||||
message = "You need to be logged in to continue."
|
|
||||||
super().__init__(message)
|
|
||||||
self.login_error: LoginError = LoginError(title="Unauthorized", message=message)
|
|
||||||
138
packages/sshecret-admin/src/sshecret_admin/core/app.py
Normal file
138
packages/sshecret-admin/src/sshecret_admin/core/app.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
"""FastAPI app."""
|
||||||
|
|
||||||
|
# pyright: reportUnusedFunction=false
|
||||||
|
#
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
import logging
|
||||||
|
import pathlib
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request, status
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
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.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__)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
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."""
|
||||||
|
LOG.info("Setting up password manager")
|
||||||
|
setup_private_key(settings, regenerate=False)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
"""Create database before starting the server."""
|
||||||
|
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(
|
||||||
|
request: Request, exc: RequestValidationError
|
||||||
|
):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
|
||||||
|
)
|
||||||
|
|
||||||
|
@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:
|
||||||
|
"""Provide simple health check."""
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_200_OK, content=jsonable_encoder({"status": "LIVE"})
|
||||||
|
)
|
||||||
|
|
||||||
|
dependencies = BaseDependencies(settings, get_db_session, get_async_session)
|
||||||
|
|
||||||
|
app.include_router(api.create_api_router(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
|
||||||
@ -1,39 +1,41 @@
|
|||||||
"""Sshecret admin CLI helper."""
|
"""Sshecret admin CLI helper."""
|
||||||
|
|
||||||
import asyncio
|
import json
|
||||||
import code
|
|
||||||
from collections.abc import Awaitable
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, cast
|
from pathlib import Path
|
||||||
import bcrypt
|
from typing import cast
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from sshecret_admin.admin_backend import AdminBackend
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from sqlmodel import Session, create_engine, select
|
from sqlalchemy import select, create_engine
|
||||||
from .auth_models import init_db, User, PasswordDB
|
from sqlalchemy.orm import Session
|
||||||
from .settings import AdminServerSettings
|
from sshecret_admin.auth.authentication import hash_password
|
||||||
|
from sshecret_admin.auth.models import AuthProvider, User
|
||||||
|
from sshecret_admin.core.app import create_admin_app
|
||||||
|
from sshecret_admin.core.settings import AdminServerSettings
|
||||||
|
|
||||||
handler = logging.StreamHandler()
|
handler = logging.StreamHandler()
|
||||||
formatter = logging.Formatter("%(asctime)s [%(processName)s: %(process)d] [%(threadName)s: %(thread)d] [%(levelname)s] %(name)s: %(message)s")
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s [%(processName)s: %(process)d] [%(threadName)s: %(thread)d] [%(levelname)s] %(name)s: %(message)s"
|
||||||
|
)
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
LOG = logging.getLogger()
|
LOG = logging.getLogger()
|
||||||
LOG.addHandler(handler)
|
LOG.addHandler(handler)
|
||||||
|
|
||||||
LOG.setLevel(logging.INFO)
|
LOG.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(session: Session, username: str, email: str, password: str) -> None:
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
|
||||||
"""Hash password."""
|
|
||||||
salt = bcrypt.gensalt()
|
|
||||||
hashed_password = bcrypt.hashpw(password.encode(), salt)
|
|
||||||
return hashed_password.decode()
|
|
||||||
|
|
||||||
def create_user(session: Session, username: str, password: str) -> None:
|
|
||||||
"""Create a user."""
|
"""Create a user."""
|
||||||
hashed_password = hash_password(password)
|
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.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@ -43,29 +45,38 @@ def create_user(session: Session, username: str, password: str) -> None:
|
|||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli(ctx: click.Context, debug: bool) -> None:
|
def cli(ctx: click.Context, debug: bool) -> None:
|
||||||
"""Sshecret Admin."""
|
"""Sshecret Admin."""
|
||||||
if debug:
|
|
||||||
LOG.setLevel(logging.DEBUG)
|
|
||||||
try:
|
try:
|
||||||
settings = AdminServerSettings() # pyright: ignore[reportCallIssue]
|
settings = AdminServerSettings() # pyright: ignore[reportCallIssue]
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
raise click.ClickException("Error: One or more required environment options are missing.") from e
|
raise click.ClickException(
|
||||||
|
"Error: One or more required environment options are missing."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
click.echo("Setting logging to debug level")
|
||||||
|
LOG.setLevel(logging.DEBUG)
|
||||||
|
settings.debug = True
|
||||||
ctx.obj = settings
|
ctx.obj = settings
|
||||||
|
|
||||||
|
|
||||||
@cli.command("adduser")
|
@cli.command("adduser")
|
||||||
@click.argument("username")
|
@click.argument("username")
|
||||||
|
@click.argument("email")
|
||||||
@click.password_option()
|
@click.password_option()
|
||||||
@click.pass_context
|
@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."""
|
"""Create user."""
|
||||||
settings = cast(AdminServerSettings, ctx.obj)
|
settings = cast(AdminServerSettings, ctx.obj)
|
||||||
engine = create_engine(settings.admin_db)
|
engine = create_engine(settings.admin_db)
|
||||||
init_db(engine)
|
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
create_user(session, username, password)
|
create_user(session, username, email, password)
|
||||||
|
|
||||||
click.echo("User created.")
|
click.echo("User created.")
|
||||||
|
|
||||||
|
|
||||||
@cli.command("passwd")
|
@cli.command("passwd")
|
||||||
@click.argument("username")
|
@click.argument("username")
|
||||||
@click.password_option()
|
@click.password_option()
|
||||||
@ -74,9 +85,8 @@ def cli_change_user_passwd(ctx: click.Context, username: str, password: str) ->
|
|||||||
"""Change password on user."""
|
"""Change password on user."""
|
||||||
settings = cast(AdminServerSettings, ctx.obj)
|
settings = cast(AdminServerSettings, ctx.obj)
|
||||||
engine = create_engine(settings.admin_db)
|
engine = create_engine(settings.admin_db)
|
||||||
init_db(engine)
|
|
||||||
with Session(engine) as session:
|
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:
|
if not user:
|
||||||
raise click.ClickException(f"Error: No such user, {username}.")
|
raise click.ClickException(f"Error: No such user, {username}.")
|
||||||
new_passwd_hash = hash_password(password)
|
new_passwd_hash = hash_password(password)
|
||||||
@ -85,6 +95,7 @@ def cli_change_user_passwd(ctx: click.Context, username: str, password: str) ->
|
|||||||
session.commit()
|
session.commit()
|
||||||
click.echo("Password updated.")
|
click.echo("Password updated.")
|
||||||
|
|
||||||
|
|
||||||
@cli.command("deluser")
|
@cli.command("deluser")
|
||||||
@click.argument("username")
|
@click.argument("username")
|
||||||
@click.confirmation_option()
|
@click.confirmation_option()
|
||||||
@ -93,9 +104,8 @@ def cli_delete_user(ctx: click.Context, username: str) -> None:
|
|||||||
"""Remove a user."""
|
"""Remove a user."""
|
||||||
settings = cast(AdminServerSettings, ctx.obj)
|
settings = cast(AdminServerSettings, ctx.obj)
|
||||||
engine = create_engine(settings.admin_db)
|
engine = create_engine(settings.admin_db)
|
||||||
init_db(engine)
|
|
||||||
with Session(engine) as session:
|
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:
|
if not user:
|
||||||
raise click.ClickException(f"Error: No such user, {username}.")
|
raise click.ClickException(f"Error: No such user, {username}.")
|
||||||
|
|
||||||
@ -110,34 +120,38 @@ def cli_delete_user(ctx: click.Context, username: str) -> None:
|
|||||||
@click.option("--port", default=8822, type=click.INT)
|
@click.option("--port", default=8822, type=click.INT)
|
||||||
@click.option("--dev", is_flag=True)
|
@click.option("--dev", is_flag=True)
|
||||||
@click.option("--workers", type=click.INT)
|
@click.option("--workers", type=click.INT)
|
||||||
def cli_run(host: str, port: int, dev: bool, workers: int | None) -> None:
|
|
||||||
"""Run the server."""
|
|
||||||
uvicorn.run("sshecret_admin.main:app", host=host, port=port, reload=dev, workers=workers)
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command("repl")
|
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def cli_repl(ctx: click.Context) -> None:
|
def cli_run(
|
||||||
"""Run an interactive console."""
|
ctx: click.Context, host: str, port: int, dev: bool, workers: int | None
|
||||||
|
) -> None:
|
||||||
|
"""Run the server."""
|
||||||
settings = cast(AdminServerSettings, ctx.obj)
|
settings = cast(AdminServerSettings, ctx.obj)
|
||||||
engine = create_engine(settings.admin_db)
|
log_level = "info"
|
||||||
init_db(engine)
|
if settings.debug:
|
||||||
with Session(engine) as session:
|
log_level = "debug"
|
||||||
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
uvicorn.run(
|
||||||
|
"sshecret_admin.core.main:app",
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
reload=dev,
|
||||||
|
workers=workers,
|
||||||
|
log_level=log_level,
|
||||||
|
)
|
||||||
|
|
||||||
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:
|
@cli.command("openapi")
|
||||||
"""Run an async function."""
|
@click.argument("destination", type=click.Path(file_okay=False, dir_okay=True, path_type=Path))
|
||||||
loop = asyncio.get_event_loop()
|
@click.pass_context
|
||||||
return loop.run_until_complete(func)
|
def cli_generate_openapi(ctx: click.Context, destination: Path) -> None:
|
||||||
|
"""Generate openapi schema.
|
||||||
|
|
||||||
admin = AdminBackend(settings, password_db.encrypted_password)
|
A openapi.json file will be written to the destination directory.
|
||||||
locals = {
|
"""
|
||||||
"run": run,
|
settings = cast(AdminServerSettings, ctx.obj)
|
||||||
"admin": admin,
|
app = create_admin_app(settings, with_frontend=False)
|
||||||
}
|
schema = app.openapi()
|
||||||
banner = "Sshecret-admin REPL\nAdmin backend API bound to 'admin'. Run async functions with run()"
|
output_file = destination / "openapi.json"
|
||||||
console = code.InteractiveConsole(locals=locals, local_exit=True)
|
with open(output_file, "w") as f:
|
||||||
console.interact(banner=banner, exitmsg="Bye!")
|
json.dump(schema, f)
|
||||||
|
|
||||||
|
click.echo(f"Wrote schema to {output_file.absolute()}")
|
||||||
96
packages/sshecret-admin/src/sshecret_admin/core/db.py
Normal file
96
packages/sshecret-admin/src/sshecret_admin/core/db.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"""Database setup."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator, Generator, Callable
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.engine import URL
|
||||||
|
from sqlalchemy import create_engine, Engine, event
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncConnection,
|
||||||
|
create_async_engine,
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_database(
|
||||||
|
db_url: URL,
|
||||||
|
) -> tuple[Engine, Callable[[], Generator[Session, None, None]]]:
|
||||||
|
"""Setup database."""
|
||||||
|
|
||||||
|
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."""
|
||||||
|
with Session(engine) as session:
|
||||||
|
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()
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
"""Common type definitions."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator, Awaitable, Callable, Generator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sshecret_admin.auth import User
|
||||||
|
from sshecret_admin.services import AdminBackend
|
||||||
|
from sshecret_admin.core.settings import AdminServerSettings
|
||||||
|
|
||||||
|
|
||||||
|
DBSessionDep = Callable[[], Generator[Session, None, None]]
|
||||||
|
AsyncSessionDep = Callable[[], AsyncGenerator[AsyncSession, None]]
|
||||||
|
|
||||||
|
AdminDep = Callable[[Request], AsyncGenerator[AdminBackend, None]]
|
||||||
|
|
||||||
|
GetUserDep = Callable[[User], Awaitable[User]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BaseDependencies:
|
||||||
|
"""Base level dependencies."""
|
||||||
|
|
||||||
|
settings: AdminServerSettings
|
||||||
|
get_db_session: DBSessionDep
|
||||||
|
get_async_session: AsyncSessionDep
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminDependencies(BaseDependencies):
|
||||||
|
"""Dependency class with admin."""
|
||||||
|
|
||||||
|
get_admin_backend: AdminDep
|
||||||
|
get_current_active_user: GetUserDep
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
deps: BaseDependencies,
|
||||||
|
get_admin_backend: AdminDep,
|
||||||
|
get_current_active_user: GetUserDep,
|
||||||
|
) -> Self:
|
||||||
|
"""Create from base dependencies."""
|
||||||
|
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,
|
||||||
|
)
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"""Main server app."""
|
"""Main server app."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import uvicorn
|
|
||||||
import click
|
import click
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
54
packages/sshecret-admin/src/sshecret_admin/core/settings.py
Normal file
54
packages/sshecret-admin/src/sshecret_admin/core/settings.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"""SSH Server settings."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from pydantic import AnyHttpUrl, BaseModel, Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from sqlalchemy import URL
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_LISTEN_PORT = 8822
|
||||||
|
|
||||||
|
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_nested_delimiter="__",
|
||||||
|
)
|
||||||
|
|
||||||
|
backend_url: AnyHttpUrl = Field(alias="sshecret_backend_url")
|
||||||
|
backend_token: str
|
||||||
|
listen_address: str = Field(default="")
|
||||||
|
secret_key: str
|
||||||
|
port: int = DEFAULT_LISTEN_PORT
|
||||||
|
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)
|
||||||
@ -1,22 +0,0 @@
|
|||||||
"""Database setup."""
|
|
||||||
|
|
||||||
from collections.abc import Generator, Callable
|
|
||||||
|
|
||||||
from sqlmodel import Session, create_engine
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.engine import URL
|
|
||||||
|
|
||||||
|
|
||||||
def setup_database(
|
|
||||||
db_url: URL | str,
|
|
||||||
) -> tuple[sa.Engine, Callable[[], Generator[Session, None, None]]]:
|
|
||||||
"""Setup database."""
|
|
||||||
|
|
||||||
engine = create_engine(db_url, echo=True)
|
|
||||||
|
|
||||||
def get_db_session() -> Generator[Session, None, None]:
|
|
||||||
"""Get DB Session."""
|
|
||||||
with Session(engine) as session:
|
|
||||||
yield session
|
|
||||||
|
|
||||||
return engine, get_db_session
|
|
||||||
@ -1,240 +0,0 @@
|
|||||||
"""Frontend methods."""
|
|
||||||
|
|
||||||
# pyright: reportUnusedFunction=false
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from datetime import timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Response, status
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
from jinja2_fragments.fastapi import Jinja2Blocks
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from sshecret_admin.settings import AdminServerSettings
|
|
||||||
from sshecret.backend import SshecretBackend
|
|
||||||
from .admin_backend import AdminBackend
|
|
||||||
from .auth_models import (
|
|
||||||
JWT_ALGORITHM,
|
|
||||||
AuthenticationFailedError,
|
|
||||||
AuthenticationNeededError,
|
|
||||||
LoginError,
|
|
||||||
PasswordDB,
|
|
||||||
User,
|
|
||||||
TokenData,
|
|
||||||
create_access_token,
|
|
||||||
verify_password,
|
|
||||||
)
|
|
||||||
from .types import DBSessionDep
|
|
||||||
from .views import create_audit_view, create_client_view, create_secrets_view
|
|
||||||
|
|
||||||
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 45
|
|
||||||
LOG = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def login_error(templates: Jinja2Blocks, request: Request):
|
|
||||||
"""Return a login error."""
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"login.html",
|
|
||||||
{
|
|
||||||
"page_title": "Login",
|
|
||||||
"page_description": "Login Page",
|
|
||||||
"error": "Invalid Login.",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_frontend(
|
|
||||||
settings: AdminServerSettings, get_db_session: DBSessionDep
|
|
||||||
) -> APIRouter:
|
|
||||||
"""Create frontend."""
|
|
||||||
app = APIRouter(include_in_schema=False)
|
|
||||||
|
|
||||||
script_path = Path(os.path.dirname(os.path.realpath(__file__)))
|
|
||||||
|
|
||||||
template_path = script_path / "templates"
|
|
||||||
|
|
||||||
templates = Jinja2Blocks(directory=template_path)
|
|
||||||
|
|
||||||
# @app.exception_handler(AuthenticationFailedError)
|
|
||||||
# async def handle_authentication_failed(request: Request, exc: AuthenticationFailedError):
|
|
||||||
# """Handle authentication failed error."""
|
|
||||||
# return templates.TemplateResponse(request, "login.html")
|
|
||||||
|
|
||||||
async def get_backend():
|
|
||||||
"""Get backend client."""
|
|
||||||
backend_client = SshecretBackend(
|
|
||||||
str(settings.backend_url), settings.backend_token
|
|
||||||
)
|
|
||||||
yield backend_client
|
|
||||||
|
|
||||||
async def get_admin_backend(session: Annotated[Session, Depends(get_db_session)]):
|
|
||||||
"""Get admin backend API."""
|
|
||||||
password_db = session.exec(select(PasswordDB).where(PasswordDB.id == 1)).first()
|
|
||||||
if not password_db:
|
|
||||||
raise HTTPException(
|
|
||||||
500, detail="Error: The password manager has not yet been set up."
|
|
||||||
)
|
|
||||||
admin = AdminBackend(settings, password_db.encrypted_password)
|
|
||||||
yield admin
|
|
||||||
|
|
||||||
async def get_login_status(
|
|
||||||
request: Request, session: Annotated[Session, Depends(get_db_session)]
|
|
||||||
) -> bool:
|
|
||||||
"""Get login status."""
|
|
||||||
token = request.cookies.get("access_token")
|
|
||||||
if not token:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM])
|
|
||||||
username = payload.get("sub")
|
|
||||||
if not username:
|
|
||||||
return False
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
return False
|
|
||||||
token_data = TokenData(username=username)
|
|
||||||
user = session.exec(
|
|
||||||
select(User).where(User.username == token_data.username)
|
|
||||||
).first()
|
|
||||||
if not user:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def get_current_user_from_token(
|
|
||||||
request: Request, session: Annotated[Session, Depends(get_db_session)]
|
|
||||||
) -> User:
|
|
||||||
credentials_exception = AuthenticationNeededError()
|
|
||||||
"""Get current user from token."""
|
|
||||||
token = request.cookies.get("access_token")
|
|
||||||
if not token:
|
|
||||||
raise credentials_exception
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, settings.secret_key, algorithms=[JWT_ALGORITHM])
|
|
||||||
username = payload.get("sub")
|
|
||||||
if not username:
|
|
||||||
raise credentials_exception
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
raise credentials_exception
|
|
||||||
token_data = TokenData(username=username)
|
|
||||||
user = session.exec(
|
|
||||||
select(User).where(User.username == token_data.username)
|
|
||||||
).first()
|
|
||||||
if not user:
|
|
||||||
raise credentials_exception
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def get_index(
|
|
||||||
request: Request,
|
|
||||||
login_status: Annotated[bool, Depends(get_login_status)],
|
|
||||||
error_title: str | None = None,
|
|
||||||
error_message: str | None = None,
|
|
||||||
):
|
|
||||||
"""Get index."""
|
|
||||||
if login_status:
|
|
||||||
return RedirectResponse("/dashboard")
|
|
||||||
login_error: LoginError | None = None
|
|
||||||
if error_title and error_message:
|
|
||||||
login_error = LoginError(title=error_title, message=error_message)
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"login.html",
|
|
||||||
{
|
|
||||||
"page_title": "Login",
|
|
||||||
"page_description": "Login page.",
|
|
||||||
"login_error": login_error,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.post("/")
|
|
||||||
async def post_index(
|
|
||||||
request: Request,
|
|
||||||
error_title: str | None = None,
|
|
||||||
error_message: str | None = None,
|
|
||||||
):
|
|
||||||
"""Get index."""
|
|
||||||
login_error: LoginError | None = None
|
|
||||||
if error_title and error_message:
|
|
||||||
login_error = LoginError(title=error_title, message=error_message)
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"login.html",
|
|
||||||
{
|
|
||||||
"page_title": "Login",
|
|
||||||
"page_description": "Login page.",
|
|
||||||
"login_error": login_error,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.post("/login")
|
|
||||||
async def login_user(
|
|
||||||
response: Response,
|
|
||||||
request: Request,
|
|
||||||
session: Annotated[Session, Depends(get_db_session)],
|
|
||||||
username: Annotated[str, Form()],
|
|
||||||
password: Annotated[str, Form()],
|
|
||||||
):
|
|
||||||
"""Log in user."""
|
|
||||||
user = session.exec(select(User).where(User.username == username)).first()
|
|
||||||
auth_error = AuthenticationFailedError()
|
|
||||||
if not user:
|
|
||||||
raise auth_error
|
|
||||||
|
|
||||||
if not verify_password(password, user.hashed_password):
|
|
||||||
raise auth_error
|
|
||||||
|
|
||||||
token_data = {"sub": user.username}
|
|
||||||
expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
||||||
token = create_access_token(settings, token_data, expires_delta=expires)
|
|
||||||
response = RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
|
|
||||||
response.set_cookie(
|
|
||||||
key="access_token", value=token, httponly=True, secure=False, samesite="lax"
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.get("/success")
|
|
||||||
async def success_page(
|
|
||||||
request: Request,
|
|
||||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
|
||||||
):
|
|
||||||
"""Display a success page."""
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request, "success.html", {"page_title": "Success!", "user": current_user}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/dashboard")
|
|
||||||
async def get_dashboard(
|
|
||||||
request: Request,
|
|
||||||
current_user: Annotated[User, Depends(get_current_user_from_token)],
|
|
||||||
admin: Annotated[AdminBackend, Depends(get_admin_backend)],
|
|
||||||
):
|
|
||||||
"""Dashboard for mocking up the dashboard."""
|
|
||||||
# secrets = await admin.get_secrets()
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"dashboard.html",
|
|
||||||
{
|
|
||||||
"page_title": "sshecret",
|
|
||||||
"user": current_user.username,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Stop adding routes here.
|
|
||||||
|
|
||||||
app.include_router(
|
|
||||||
create_client_view(templates, get_current_user_from_token, get_admin_backend)
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(
|
|
||||||
create_secrets_view(templates, get_current_user_from_token, get_admin_backend)
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(
|
|
||||||
create_audit_view(templates, get_current_user_from_token, get_admin_backend)
|
|
||||||
)
|
|
||||||
|
|
||||||
return app
|
|
||||||
@ -1,116 +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 .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
|
|
||||||
elif 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
|
|
||||||
@ -1,82 +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 .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.
|
|
||||||
"""
|
|
||||||
created = _initial_key_setup(settings, filename, regenerate)
|
|
||||||
if not created:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return _generate_master_password(settings, filename)
|
|
||||||
|
|
||||||
|
|
||||||
def decrypt_master_password(
|
|
||||||
settings: AdminServerSettings, encrypted: str, filename: str = KEY_FILENAME
|
|
||||||
) -> str:
|
|
||||||
"""Retrieve master password."""
|
|
||||||
keyfile = Path(filename)
|
|
||||||
if not keyfile.exists():
|
|
||||||
raise RuntimeError("Error: Private key has not been generated yet.")
|
|
||||||
|
|
||||||
private_key = load_private_key(KEY_FILENAME, 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,
|
|
||||||
filename: str = KEY_FILENAME,
|
|
||||||
regenerate: bool = False,
|
|
||||||
) -> bool:
|
|
||||||
"""Set up initial keys."""
|
|
||||||
keyfile = Path(filename)
|
|
||||||
|
|
||||||
if keyfile.exists() and not regenerate:
|
|
||||||
return False
|
|
||||||
|
|
||||||
assert settings.secret_key is not None, (
|
|
||||||
"Error: Could not load a secret key from environment."
|
|
||||||
)
|
|
||||||
create_private_rsa_key(keyfile, password=settings.secret_key)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_master_password(
|
|
||||||
settings: AdminServerSettings, filename: str = KEY_FILENAME
|
|
||||||
) -> str:
|
|
||||||
"""Generate master password for password database.
|
|
||||||
|
|
||||||
Returns the encrypted string, base64 encoded.
|
|
||||||
"""
|
|
||||||
keyfile = Path(filename)
|
|
||||||
if not keyfile.exists():
|
|
||||||
raise RuntimeError("Error: Private key has not been generated yet.")
|
|
||||||
private_key = load_private_key(filename, password=settings.secret_key)
|
|
||||||
public_key = private_key.public_key()
|
|
||||||
master_password = _generate_password()
|
|
||||||
return encrypt_string(master_password, public_key)
|
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
"""Services module.
|
||||||
|
|
||||||
|
This module contains business logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .admin_backend import AdminBackend
|
||||||
|
|
||||||
|
__all__ = ["AdminBackend"]
|
||||||
@ -0,0 +1,775 @@
|
|||||||
|
"""API for working with the clients.
|
||||||
|
|
||||||
|
Since we have a frontend and a REST API, it makes sense to have a generic library to work with the clients.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Literal, Unpack
|
||||||
|
|
||||||
|
from sshecret.backend import (
|
||||||
|
AuditLog,
|
||||||
|
AuditListResult,
|
||||||
|
Client,
|
||||||
|
ClientFilter,
|
||||||
|
SshecretBackend,
|
||||||
|
Operation,
|
||||||
|
SubSystem,
|
||||||
|
)
|
||||||
|
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 .secret_manager import (
|
||||||
|
AsyncSecretContext,
|
||||||
|
InvalidSecretNameError,
|
||||||
|
SecretUpdateParams,
|
||||||
|
password_manager_context,
|
||||||
|
)
|
||||||
|
from sshecret_admin.core.settings import AdminServerSettings
|
||||||
|
from .models import (
|
||||||
|
ClientSecretGroup,
|
||||||
|
ClientSecretGroupList,
|
||||||
|
GroupReference,
|
||||||
|
SecretClientMapping,
|
||||||
|
SecretListView,
|
||||||
|
SecretGroup,
|
||||||
|
SecretView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientManagementError(Exception):
|
||||||
|
"""Base exception for client management operations."""
|
||||||
|
|
||||||
|
|
||||||
|
class ClientNotFoundError(ClientManagementError):
|
||||||
|
"""Client not found."""
|
||||||
|
|
||||||
|
|
||||||
|
class SecretNotFoundError(ClientManagementError):
|
||||||
|
"""Secret not found."""
|
||||||
|
|
||||||
|
|
||||||
|
class BackendUnavailableError(ClientManagementError):
|
||||||
|
"""Backend unavailable."""
|
||||||
|
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
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.username: str = username or "UKNOWN_USER"
|
||||||
|
self.origin: str = origin
|
||||||
|
|
||||||
|
@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."""
|
||||||
|
return await self.backend.get_clients(filter)
|
||||||
|
|
||||||
|
async def get_clients(self, filter: ClientFilter | None = None) -> list[Client]:
|
||||||
|
"""Get clients from backend."""
|
||||||
|
try:
|
||||||
|
return await self._get_clients(filter)
|
||||||
|
except ClientManagementError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise BackendUnavailableError() from e
|
||||||
|
|
||||||
|
async def get_clients_terse(self) -> list[ClientReference]:
|
||||||
|
"""Get a list of client ids and names."""
|
||||||
|
return await self.backend.get_client_terse()
|
||||||
|
|
||||||
|
async def query_clients(
|
||||||
|
self, filter: ClientFilter | None = None
|
||||||
|
) -> ClientQueryResult:
|
||||||
|
"""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(idname)
|
||||||
|
if not client:
|
||||||
|
raise ClientNotFoundError()
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def verify_client_exists(self, name: str) -> None:
|
||||||
|
"""Check that a client exists."""
|
||||||
|
try:
|
||||||
|
await self._verify_client_exists(name)
|
||||||
|
except ClientManagementError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise BackendUnavailableError() from e
|
||||||
|
|
||||||
|
async def get_client(self, name: KeySpec) -> Client | None:
|
||||||
|
"""Get a client from the backend."""
|
||||||
|
try:
|
||||||
|
return await self._get_client(name)
|
||||||
|
except ClientManagementError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise BackendUnavailableError() from e
|
||||||
|
|
||||||
|
async def 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,
|
||||||
|
public_key: str,
|
||||||
|
description: str | None = None,
|
||||||
|
sources: list[str] | None = None,
|
||||||
|
) -> Client:
|
||||||
|
"""Create client."""
|
||||||
|
await self.backend.create_client(name, public_key, description)
|
||||||
|
if sources:
|
||||||
|
await self.backend.update_client_sources(name, sources)
|
||||||
|
client = await self.get_client(name)
|
||||||
|
|
||||||
|
if not client:
|
||||||
|
raise ClientNotFoundError()
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
async def create_client(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
public_key: str,
|
||||||
|
description: str | None = None,
|
||||||
|
sources: list[str] | None = None,
|
||||||
|
) -> Client:
|
||||||
|
"""Create client."""
|
||||||
|
try:
|
||||||
|
return await self._create_client(name, public_key, description, sources)
|
||||||
|
except ClientManagementError:
|
||||||
|
raise
|
||||||
|
except BackendValidationError as e:
|
||||||
|
LOG.error("Validation error: %s", e, exc_info=True)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
except BackendError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error("Exception: %s", e, exc_info=True)
|
||||||
|
raise BackendUnavailableError() from e
|
||||||
|
|
||||||
|
async def _update_client_public_key(
|
||||||
|
self,
|
||||||
|
name: KeySpec,
|
||||||
|
new_key: str,
|
||||||
|
password_manager: AsyncSecretContext,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Update client public key."""
|
||||||
|
LOG.info(
|
||||||
|
"Updating client %s public key. This will invalidate all existing secrets."
|
||||||
|
)
|
||||||
|
client = await self.get_client(name)
|
||||||
|
if not client:
|
||||||
|
raise ClientNotFoundError()
|
||||||
|
await self.backend.update_client_key(name, new_key)
|
||||||
|
updated_secrets: list[str] = []
|
||||||
|
for secret in client.secrets:
|
||||||
|
LOG.debug("Re-encrypting secret %s for client %s", secret, name)
|
||||||
|
secret_value = await password_manager.get_secret(secret)
|
||||||
|
if not secret_value:
|
||||||
|
LOG.warning(
|
||||||
|
"Referenced secret %s does not exist! Skipping.", secret_value
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
rsa_public_key = load_public_key(client.public_key.encode())
|
||||||
|
encrypted = encrypt_string(secret_value, rsa_public_key)
|
||||||
|
LOG.debug("Sending new encrypted value to backend.")
|
||||||
|
await self.backend.create_client_secret(name, secret, encrypted)
|
||||||
|
updated_secrets.append(secret)
|
||||||
|
|
||||||
|
return updated_secrets
|
||||||
|
|
||||||
|
async def update_client_public_key(self, name: KeySpec, new_key: str) -> list[str]:
|
||||||
|
"""Update client public key."""
|
||||||
|
try:
|
||||||
|
async with self.secrets_manager() as password_manager:
|
||||||
|
return await self._update_client_public_key(
|
||||||
|
name, new_key, password_manager
|
||||||
|
)
|
||||||
|
except ClientManagementError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise BackendUnavailableError() from e
|
||||||
|
|
||||||
|
async def _update_client(self, new_client: Client) -> Client:
|
||||||
|
"""Update a client object."""
|
||||||
|
existing_client = await self.get_client(new_client.name)
|
||||||
|
if not existing_client:
|
||||||
|
raise ClientNotFoundError()
|
||||||
|
await self.backend.update_client(new_client)
|
||||||
|
if new_client.public_key != existing_client.public_key:
|
||||||
|
await self.update_client_public_key(new_client.name, new_client.public_key)
|
||||||
|
|
||||||
|
updated_client = await self.get_client(new_client.name)
|
||||||
|
if not updated_client:
|
||||||
|
raise ClientNotFoundError()
|
||||||
|
return updated_client
|
||||||
|
|
||||||
|
async def update_client(self, new_client: Client) -> Client:
|
||||||
|
"""Update a client object."""
|
||||||
|
try:
|
||||||
|
return await self._update_client(new_client)
|
||||||
|
except ClientManagementError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise BackendUnavailableError() from e
|
||||||
|
|
||||||
|
async def update_client_sources(self, name: 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: KeySpec) -> None:
|
||||||
|
"""Delete client."""
|
||||||
|
await self.backend.delete_client(name)
|
||||||
|
|
||||||
|
async def delete_client(self, name: KeySpec) -> None:
|
||||||
|
"""Delete client."""
|
||||||
|
try:
|
||||||
|
await self._delete_client(name)
|
||||||
|
except ClientManagementError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise BackendUnavailableError() from e
|
||||||
|
|
||||||
|
async def delete_client_secret(
|
||||||
|
self, client_name: 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[SecretListView]:
|
||||||
|
"""Get secrets.
|
||||||
|
|
||||||
|
This fetches the secret to client mapping from backend, and adds secrets from the password manager.
|
||||||
|
"""
|
||||||
|
backend_secrets = await self.backend.get_secrets()
|
||||||
|
async with self.secrets_manager() as password_manager:
|
||||||
|
admin_secrets = await password_manager.get_available_secrets()
|
||||||
|
|
||||||
|
secrets: dict[str, SecretListView] = {}
|
||||||
|
for secret in backend_secrets:
|
||||||
|
secrets[secret.name] = SecretListView(
|
||||||
|
name=secret.name, unmanaged=True, clients=secret.clients
|
||||||
|
)
|
||||||
|
|
||||||
|
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=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return list(secrets.values())
|
||||||
|
|
||||||
|
async def get_secrets(self) -> list[SecretListView]:
|
||||||
|
"""Get secrets from backend."""
|
||||||
|
try:
|
||||||
|
return await self._get_secrets()
|
||||||
|
except ClientManagementError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise BackendUnavailableError() from e
|
||||||
|
|
||||||
|
async def _get_detailed_secrets(self) -> list[DetailedSecrets]:
|
||||||
|
"""Get detailed secrets.
|
||||||
|
|
||||||
|
This fetches the secret to client mapping from backend, and adds secrets from the password manager.
|
||||||
|
"""
|
||||||
|
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]
|
||||||
|
for secret in all_secrets:
|
||||||
|
if secret not in backend_secret_names:
|
||||||
|
secrets.append(DetailedSecrets(name=secret, ids=[], clients=[]))
|
||||||
|
|
||||||
|
return secrets
|
||||||
|
|
||||||
|
async def get_detailed_secrets(self) -> list[DetailedSecrets]:
|
||||||
|
"""Get detailed secrets from backend."""
|
||||||
|
try:
|
||||||
|
return await self._get_detailed_secrets()
|
||||||
|
except ClientManagementError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise BackendUnavailableError() from e
|
||||||
|
|
||||||
|
async def 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:
|
||||||
|
return await self._get_secret(name)
|
||||||
|
except ClientManagementError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise BackendUnavailableError() from e
|
||||||
|
|
||||||
|
async def _get_secret(
|
||||||
|
self, name: str, secret_id: str | None = None
|
||||||
|
) -> SecretView | None:
|
||||||
|
"""Get a secret, including the actual unencrypted value and clients."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
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
|
||||||
|
|
||||||
|
async def delete_secret(self, name: str) -> None:
|
||||||
|
"""Delete a secret."""
|
||||||
|
try:
|
||||||
|
return await self._delete_secret(name)
|
||||||
|
except ClientManagementError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise BackendUnavailableError() from e
|
||||||
|
|
||||||
|
async def _delete_secret(self, name: str) -> None:
|
||||||
|
"""Delete a secret."""
|
||||||
|
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(("id", client.id), name)
|
||||||
|
|
||||||
|
async def _add_secret(
|
||||||
|
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."""
|
||||||
|
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 = [ref.name for ref in secret_map.clients]
|
||||||
|
|
||||||
|
if not clients:
|
||||||
|
return
|
||||||
|
for client_name in clients:
|
||||||
|
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(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 %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,
|
||||||
|
group: str | None = None,
|
||||||
|
distinguisher: Literal["name", "id"] = "name",
|
||||||
|
) -> None:
|
||||||
|
"""Add a secret."""
|
||||||
|
try:
|
||||||
|
await self._add_secret(
|
||||||
|
name=name,
|
||||||
|
value=value,
|
||||||
|
clients=clients,
|
||||||
|
group=group,
|
||||||
|
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 ClientManagementError(e)
|
||||||
|
|
||||||
|
async def update_secret(self, name: str, value: str) -> None:
|
||||||
|
"""Update secrets."""
|
||||||
|
try:
|
||||||
|
await self._add_secret(name, value, None, True)
|
||||||
|
except ClientManagementError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise BackendUnavailableError() from e
|
||||||
|
|
||||||
|
async def _create_client_secret(
|
||||||
|
self, client_idname: KeySpec, secret_name: str
|
||||||
|
) -> None:
|
||||||
|
"""Create client secret."""
|
||||||
|
client = await self.get_client(client_idname)
|
||||||
|
if not client:
|
||||||
|
raise ClientNotFoundError(client_idname)
|
||||||
|
|
||||||
|
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_idname, secret_name, encrypted)
|
||||||
|
|
||||||
|
async def create_client_secret(
|
||||||
|
self, client_idname: KeySpec, secret_name: str
|
||||||
|
) -> None:
|
||||||
|
"""Create client secret."""
|
||||||
|
try:
|
||||||
|
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."""
|
||||||
|
return self.backend.audit(SubSystem.ADMIN)
|
||||||
|
|
||||||
|
async def get_audit_log(
|
||||||
|
self,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
**kwargs: str,
|
||||||
|
) -> list[AuditLog]:
|
||||||
|
"""Get audit log from backend.
|
||||||
|
|
||||||
|
Keyword Arguments:
|
||||||
|
operation: str | None
|
||||||
|
subsystem: str | None
|
||||||
|
client_id: str | None
|
||||||
|
client_name: str | None
|
||||||
|
secret_id: str | None
|
||||||
|
secret_name: str | None
|
||||||
|
origin: str | None
|
||||||
|
"""
|
||||||
|
return await self.audit.get(offset, limit, **kwargs)
|
||||||
|
|
||||||
|
async def get_audit_log_detailed(
|
||||||
|
self,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
**kwargs: str,
|
||||||
|
) -> AuditListResult:
|
||||||
|
"""Get audit log from backend.
|
||||||
|
|
||||||
|
Keyword Arguments:
|
||||||
|
operation: str | None
|
||||||
|
subsystem: str | None
|
||||||
|
client_id: str | None
|
||||||
|
client_name: str | None
|
||||||
|
secret_id: str | None
|
||||||
|
secret_name: str | None
|
||||||
|
origin: str | None
|
||||||
|
"""
|
||||||
|
return await self.audit.get_detailed(offset, limit, **kwargs)
|
||||||
|
|
||||||
|
async def write_audit_message(
|
||||||
|
self,
|
||||||
|
operation: Operation,
|
||||||
|
message: str,
|
||||||
|
origin: str,
|
||||||
|
client: Client | None = None,
|
||||||
|
secret_name: str | None = None,
|
||||||
|
**data: str,
|
||||||
|
) -> None:
|
||||||
|
"""Write an audit message."""
|
||||||
|
await self.audit.write_async(
|
||||||
|
operation=operation,
|
||||||
|
message=message,
|
||||||
|
origin=origin,
|
||||||
|
client=client,
|
||||||
|
secret=None,
|
||||||
|
secret_name=secret_name,
|
||||||
|
**data,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def write_audit_log(self, entry: AuditLog) -> None:
|
||||||
|
"""Write to the audit log."""
|
||||||
|
if not entry.subsystem:
|
||||||
|
entry.subsystem = SubSystem.ADMIN
|
||||||
|
|
||||||
|
await self.audit.write_model_async(entry)
|
||||||
|
# await self.backend.add_audit_log(entry)
|
||||||
|
|
||||||
|
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))
|
||||||
274
packages/sshecret-admin/src/sshecret_admin/services/models.py
Normal file
274
packages/sshecret-admin/src/sshecret_admin/services/models.py
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
"""Models for the API."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from typing import Annotated, Literal, Self
|
||||||
|
import uuid
|
||||||
|
from pydantic import (
|
||||||
|
AfterValidator,
|
||||||
|
BaseModel,
|
||||||
|
ConfigDict,
|
||||||
|
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:
|
||||||
|
"""Public key validator."""
|
||||||
|
if validate_public_key(value):
|
||||||
|
return value
|
||||||
|
raise ValueError("Error: Public key must be a valid RSA public key.")
|
||||||
|
|
||||||
|
|
||||||
|
class SecretListView(BaseModel):
|
||||||
|
"""Model containing a list of all available secrets."""
|
||||||
|
|
||||||
|
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 | None
|
||||||
|
group: GroupReference | None = None
|
||||||
|
clients: list[ClientReference] = Field(
|
||||||
|
default_factory=list
|
||||||
|
) # Clients that have access to it.
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateKeyModel(BaseModel):
|
||||||
|
"""Model for updating client public key."""
|
||||||
|
|
||||||
|
public_key: Annotated[str, AfterValidator(public_key_validator)]
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateKeyResponse(BaseModel):
|
||||||
|
"""Response model after updating the public key."""
|
||||||
|
|
||||||
|
public_key: str
|
||||||
|
updated_secrets: list[str] = Field(default_factory=list)
|
||||||
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePoliciesRequest(BaseModel):
|
||||||
|
"""Update policy request."""
|
||||||
|
|
||||||
|
sources: list[IPvAnyAddress | IPvAnyNetwork]
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoGenerateOpts(BaseModel):
|
||||||
|
"""Option to auto-generate a password."""
|
||||||
|
|
||||||
|
auto_generate: Literal[True]
|
||||||
|
length: int = 32
|
||||||
|
|
||||||
|
|
||||||
|
class SecretUpdate(BaseModel):
|
||||||
|
"""Model to update a secret."""
|
||||||
|
|
||||||
|
value: str | AutoGenerateOpts = Field(
|
||||||
|
description="Secret as string value or auto-generated with optional length",
|
||||||
|
examples=["MySecretString", {"auto_generate": True, "length": 32}],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_secret(self) -> str:
|
||||||
|
"""Get secret.
|
||||||
|
|
||||||
|
This returns the specified one, or generates one according to auto-generation.
|
||||||
|
"""
|
||||||
|
if isinstance(self.value, str):
|
||||||
|
return self.value
|
||||||
|
secret = secrets.token_urlsafe(32)[: self.value.length]
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
class SecretCreate(SecretUpdate):
|
||||||
|
"""Model to create a secret."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
clients: list[str] | None = Field(
|
||||||
|
default=None, description="Assign the secret to a list of clients."
|
||||||
|
)
|
||||||
|
client_distinguisher: Literal["id", "name"] = "name"
|
||||||
|
group: str | None = None
|
||||||
|
|
||||||
|
model_config: ConfigDict = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"name": "MySecret",
|
||||||
|
"clients": ["client-1", "client-2"],
|
||||||
|
"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
|
||||||
@ -0,0 +1,888 @@
|
|||||||
|
"""Rewritten secret manager using a rsa keys."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import NotRequired, TypedDict, Unpack
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from functools import cached_property
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload, aliased
|
||||||
|
from sshecret.backend import SshecretBackend
|
||||||
|
from sshecret.backend.api import AuditAPI
|
||||||
|
from sshecret.backend.identifiers import KeySpec
|
||||||
|
from sshecret.backend.models import Client, ClientSecret, Operation, SubSystem
|
||||||
|
from sshecret.crypto import (
|
||||||
|
create_private_rsa_key,
|
||||||
|
decode_string,
|
||||||
|
encrypt_string,
|
||||||
|
generate_public_key_string,
|
||||||
|
load_private_key,
|
||||||
|
load_public_key,
|
||||||
|
)
|
||||||
|
from sshecret_admin.auth import PasswordDB
|
||||||
|
from sshecret_admin.auth.models import Group, ManagedSecret
|
||||||
|
from sshecret_admin.core.db import DatabaseSessionManager
|
||||||
|
from sshecret_admin.core.settings import AdminServerSettings
|
||||||
|
from sshecret_admin.services.models import GroupReference, SecretGroup
|
||||||
|
|
||||||
|
|
||||||
|
KEY_FILENAME = "sshecret-admin-key"
|
||||||
|
PASSWORD_MANAGER_ID = "SshecretAdminPasswordManager"
|
||||||
|
|
||||||
|
LOG = logging.getLogger(PASSWORD_MANAGER_ID)
|
||||||
|
|
||||||
|
|
||||||
|
class SecretManagerError(Exception):
|
||||||
|
"""Secret manager error."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidGroupNameError(SecretManagerError):
|
||||||
|
"""Invalid group name."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidSecretNameError(SecretManagerError):
|
||||||
|
"""Invalid secret name."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClientAuditData:
|
||||||
|
"""Client audit data."""
|
||||||
|
|
||||||
|
username: str
|
||||||
|
origin: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedPath:
|
||||||
|
"""Parsed path."""
|
||||||
|
|
||||||
|
item: str
|
||||||
|
full_path: str
|
||||||
|
parent: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SecretDataEntryExport(BaseModel):
|
||||||
|
"""Exportable secret entry."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
secret: str
|
||||||
|
group: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SecretDataGroupExport(BaseModel):
|
||||||
|
"""Exportable secret grouping."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SecretDataExport(BaseModel):
|
||||||
|
"""Exportable object containing secrets and groups."""
|
||||||
|
|
||||||
|
entries: list[SecretDataEntryExport]
|
||||||
|
groups: list[SecretDataGroupExport]
|
||||||
|
|
||||||
|
|
||||||
|
class SecretUpdateParams(TypedDict):
|
||||||
|
"""Secret update parameters."""
|
||||||
|
|
||||||
|
name: NotRequired[str]
|
||||||
|
description: NotRequired[str]
|
||||||
|
parent: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
def split_path(path: str) -> list[str]:
|
||||||
|
"""Split a path into a list of groups."""
|
||||||
|
elements = path.split("/")
|
||||||
|
if path.startswith("/"):
|
||||||
|
elements = elements[1:]
|
||||||
|
|
||||||
|
return elements
|
||||||
|
|
||||||
|
|
||||||
|
def parse_path(path: str) -> ParsedPath:
|
||||||
|
"""Parse path."""
|
||||||
|
elements = split_path(path)
|
||||||
|
parsed = ParsedPath(elements[-1], path)
|
||||||
|
if len(elements) > 1:
|
||||||
|
parsed.parent = elements[-2]
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncSecretContext:
|
||||||
|
"""Async secret context."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
private_key: rsa.RSAPrivateKey,
|
||||||
|
manager_client: Client,
|
||||||
|
session: AsyncSession,
|
||||||
|
backend: SshecretBackend,
|
||||||
|
audit_data: ClientAuditData,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize secret manager"""
|
||||||
|
self._private_key: rsa.RSAPrivateKey = private_key
|
||||||
|
self._manager_client: Client = manager_client
|
||||||
|
self._id: KeySpec = ("id", str(manager_client.id))
|
||||||
|
self.backend: SshecretBackend = backend
|
||||||
|
self.session: AsyncSession = session
|
||||||
|
|
||||||
|
self.audit_data: ClientAuditData = audit_data
|
||||||
|
self.audit: AuditAPI = backend.audit(SubSystem.ADMIN)
|
||||||
|
self._import_has_run: bool = False
|
||||||
|
|
||||||
|
async def _create_missing_entries(self) -> None:
|
||||||
|
"""Create any missing entries."""
|
||||||
|
new_secrets: bool = False
|
||||||
|
to_check = set(self._manager_client.secrets)
|
||||||
|
for secret_name in to_check:
|
||||||
|
# entry = await self._get_entry(secret_name, include_deleted=True)
|
||||||
|
statement = select(ManagedSecret).where(ManagedSecret.name == secret_name)
|
||||||
|
result = await self.session.scalars(statement)
|
||||||
|
if not result.first():
|
||||||
|
new_secrets = True
|
||||||
|
managed_secret = ManagedSecret(name=secret_name)
|
||||||
|
self.session.add(managed_secret)
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
await self.write_audit(
|
||||||
|
Operation.CREATE,
|
||||||
|
message="Imported managed secret from backend.",
|
||||||
|
secret_name=secret_name,
|
||||||
|
managed_secret=managed_secret,
|
||||||
|
)
|
||||||
|
if new_secrets:
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
async def _get_group_depth(self, group: Group) -> int:
|
||||||
|
"""Get the depth of a group."""
|
||||||
|
depth = 1
|
||||||
|
if not group.parent_id:
|
||||||
|
return depth
|
||||||
|
|
||||||
|
current = group
|
||||||
|
while current.parent is not None:
|
||||||
|
if current.parent:
|
||||||
|
depth += 1
|
||||||
|
current = await self._get_group_by_id(current.parent.id)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return depth
|
||||||
|
|
||||||
|
async def _get_group_path(self, group: Group) -> str:
|
||||||
|
"""Get the path of a group."""
|
||||||
|
|
||||||
|
if not group.parent_id:
|
||||||
|
return group.name
|
||||||
|
path: list[str] = []
|
||||||
|
current = group
|
||||||
|
while current.parent_id is not None:
|
||||||
|
path.append(current.name)
|
||||||
|
current = await self._get_group_by_id(current.parent_id)
|
||||||
|
|
||||||
|
path.append("")
|
||||||
|
path.reverse()
|
||||||
|
return "/".join(path)
|
||||||
|
|
||||||
|
async def _get_group_secrets(self, group: Group) -> list[ManagedSecret]:
|
||||||
|
"""Get secrets in a group."""
|
||||||
|
statement = (
|
||||||
|
select(ManagedSecret)
|
||||||
|
.where(ManagedSecret.group_id == group.id)
|
||||||
|
.where(ManagedSecret.is_deleted.is_not(True))
|
||||||
|
)
|
||||||
|
results = await self.session.scalars(statement)
|
||||||
|
return list(results.all())
|
||||||
|
|
||||||
|
async def _build_group_tree(
|
||||||
|
self, group: Group, parent: SecretGroup | None = None, depth: int | None = None
|
||||||
|
) -> SecretGroup:
|
||||||
|
"""Build a group tree."""
|
||||||
|
path = "/"
|
||||||
|
if parent:
|
||||||
|
path = parent.path
|
||||||
|
|
||||||
|
path = os.path.join(path, group.name)
|
||||||
|
secret_group = SecretGroup(
|
||||||
|
id=group.id, name=group.name, path=path, description=group.description
|
||||||
|
)
|
||||||
|
group_secrets = await self._get_group_secrets(group)
|
||||||
|
for secret in group_secrets:
|
||||||
|
secret_group.entries.append(secret.name)
|
||||||
|
if parent:
|
||||||
|
secret_group.parent_group = parent
|
||||||
|
|
||||||
|
current_depth = await self._get_group_depth(group)
|
||||||
|
|
||||||
|
if not parent and group.parent:
|
||||||
|
parent_group = await self._get_group_by_id(group.parent.id)
|
||||||
|
assert parent_group is not None
|
||||||
|
parent = await self._build_group_tree(parent_group, depth=current_depth)
|
||||||
|
path = os.path.join(parent.path, group.name)
|
||||||
|
secret_group.path = path
|
||||||
|
parent.children.append(secret_group)
|
||||||
|
secret_group.parent_group = parent
|
||||||
|
|
||||||
|
if depth and depth == current_depth:
|
||||||
|
return secret_group
|
||||||
|
|
||||||
|
for subgroup in group.children:
|
||||||
|
LOG.debug(
|
||||||
|
"group: %s, subgroup: %s path=%r, group_path: %r, parent: %r",
|
||||||
|
group.name,
|
||||||
|
subgroup.name,
|
||||||
|
path,
|
||||||
|
secret_group.path,
|
||||||
|
bool(parent),
|
||||||
|
)
|
||||||
|
child_group = await self._get_group_by_id(subgroup.id)
|
||||||
|
assert child_group is not None
|
||||||
|
secret_subgroup = await self._build_group_tree(
|
||||||
|
child_group, secret_group, depth=depth
|
||||||
|
)
|
||||||
|
secret_group.children.append(secret_subgroup)
|
||||||
|
|
||||||
|
return secret_group
|
||||||
|
|
||||||
|
async def write_audit(
|
||||||
|
self,
|
||||||
|
operation: Operation,
|
||||||
|
message: str,
|
||||||
|
group_name: str | None = None,
|
||||||
|
client_secret: ClientSecret | None = None,
|
||||||
|
secret_name: str | None = None,
|
||||||
|
managed_secret: ManagedSecret | None = None,
|
||||||
|
**data: str,
|
||||||
|
) -> None:
|
||||||
|
"""Write Audit message."""
|
||||||
|
if group_name:
|
||||||
|
data["group"] = group_name
|
||||||
|
|
||||||
|
data["username"] = self.audit_data.username
|
||||||
|
if client_secret and not secret_name:
|
||||||
|
secret_name = client_secret.name
|
||||||
|
|
||||||
|
if managed_secret:
|
||||||
|
data["managed_secret"] = str(managed_secret.id)
|
||||||
|
|
||||||
|
await self.audit.write_async(
|
||||||
|
operation=operation,
|
||||||
|
message=message,
|
||||||
|
origin=self.audit_data.origin,
|
||||||
|
client=self._manager_client,
|
||||||
|
secret=client_secret,
|
||||||
|
secret_name=secret_name,
|
||||||
|
**data,
|
||||||
|
)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def public_key(self) -> rsa.RSAPublicKey:
|
||||||
|
"""Get public key."""
|
||||||
|
keystring = self._manager_client.public_key
|
||||||
|
return load_public_key(keystring.encode())
|
||||||
|
|
||||||
|
async def _get_entry(
|
||||||
|
self, name: str, include_deleted: bool = False
|
||||||
|
) -> ManagedSecret | None:
|
||||||
|
"""Get managed secret."""
|
||||||
|
if not self._import_has_run:
|
||||||
|
await self._create_missing_entries()
|
||||||
|
self._import_has_run = True
|
||||||
|
statement = (
|
||||||
|
select(ManagedSecret)
|
||||||
|
.options(selectinload(ManagedSecret.group))
|
||||||
|
.where(ManagedSecret.name == name)
|
||||||
|
)
|
||||||
|
if not include_deleted:
|
||||||
|
statement = statement.where(ManagedSecret.is_deleted.is_not(True))
|
||||||
|
|
||||||
|
result = await self.session.scalars(statement)
|
||||||
|
return result.first()
|
||||||
|
|
||||||
|
async def add_entry(
|
||||||
|
self,
|
||||||
|
entry_name: str,
|
||||||
|
secret: str,
|
||||||
|
overwrite: bool = False,
|
||||||
|
group_path: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Add entry."""
|
||||||
|
existing_entry = await self._get_entry(entry_name)
|
||||||
|
if existing_entry and not overwrite:
|
||||||
|
raise InvalidSecretNameError(
|
||||||
|
"Another secret with this name is already defined."
|
||||||
|
)
|
||||||
|
|
||||||
|
encrypted = encrypt_string(secret, self.public_key)
|
||||||
|
client_secret = await self.backend.create_client_secret(
|
||||||
|
self._id, entry_name, encrypted
|
||||||
|
)
|
||||||
|
group_id: uuid.UUID | None = None
|
||||||
|
if group_path:
|
||||||
|
elements = parse_path(group_path)
|
||||||
|
group = await self._get_group(elements.item, elements.parent, True)
|
||||||
|
if not group:
|
||||||
|
raise InvalidGroupNameError("Invalid group name")
|
||||||
|
group_id = group.id
|
||||||
|
|
||||||
|
if existing_entry:
|
||||||
|
existing_entry.updated_at = datetime.now(timezone.utc)
|
||||||
|
if group_id:
|
||||||
|
existing_entry.group_id = group_id
|
||||||
|
self.session.add(existing_entry)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.write_audit(
|
||||||
|
Operation.UPDATE,
|
||||||
|
"Updated secret value",
|
||||||
|
group_name=group_path,
|
||||||
|
client_secret=client_secret,
|
||||||
|
managed_secret=existing_entry,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
managed_secret = ManagedSecret(
|
||||||
|
name=entry_name,
|
||||||
|
group_id=group_id,
|
||||||
|
)
|
||||||
|
self.session.add(managed_secret)
|
||||||
|
|
||||||
|
await self.session.commit()
|
||||||
|
await self.write_audit(
|
||||||
|
Operation.CREATE,
|
||||||
|
"Created managed client secret",
|
||||||
|
group_path,
|
||||||
|
client_secret=client_secret,
|
||||||
|
managed_secret=managed_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_secret(self, entry_name: str) -> str | None:
|
||||||
|
"""Get secret."""
|
||||||
|
client_secret = await self.backend.get_client_secret(
|
||||||
|
self._id, ("name", entry_name)
|
||||||
|
)
|
||||||
|
if not client_secret:
|
||||||
|
return None
|
||||||
|
decrypted = decode_string(client_secret, self._private_key)
|
||||||
|
await self.write_audit(
|
||||||
|
Operation.READ,
|
||||||
|
"Secret was viewed from secret manager",
|
||||||
|
secret_name=entry_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return decrypted
|
||||||
|
|
||||||
|
async def get_available_secrets(self, group_path: str | None = None) -> list[str]:
|
||||||
|
"""Get the names of all secrets in the db."""
|
||||||
|
if not self._import_has_run:
|
||||||
|
await self._create_missing_entries()
|
||||||
|
if group_path:
|
||||||
|
elements = parse_path(group_path)
|
||||||
|
group = await self._get_group(elements.item, elements.parent)
|
||||||
|
if not group:
|
||||||
|
raise InvalidGroupNameError("Invalid or nonexisting group name.")
|
||||||
|
entries = group.secrets
|
||||||
|
else:
|
||||||
|
result = await self.session.scalars(
|
||||||
|
select(ManagedSecret)
|
||||||
|
.options(selectinload(ManagedSecret.group))
|
||||||
|
.where(ManagedSecret.is_deleted.is_not(True))
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = list(result.all())
|
||||||
|
|
||||||
|
return [entry.name for entry in entries]
|
||||||
|
|
||||||
|
async def delete_entry(self, entry_name: str) -> None:
|
||||||
|
"""Delete a secret."""
|
||||||
|
entry = await self._get_entry(entry_name)
|
||||||
|
if not entry:
|
||||||
|
return
|
||||||
|
entry.is_deleted = True
|
||||||
|
entry.deleted_at = datetime.now(timezone.utc)
|
||||||
|
self.session.add(entry)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.backend.delete_client_secret(
|
||||||
|
("id", str(self._manager_client.id)), ("name", entry_name)
|
||||||
|
)
|
||||||
|
await self.write_audit(
|
||||||
|
Operation.DELETE,
|
||||||
|
"Managed secret entry deleted",
|
||||||
|
secret_name=entry_name,
|
||||||
|
managed_secret=entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_entry_group(self, entry_name: str) -> str | None:
|
||||||
|
"""Get group of entry."""
|
||||||
|
entry = await self._get_entry(entry_name)
|
||||||
|
if not entry:
|
||||||
|
raise InvalidSecretNameError("Invalid secret name or secret not found.")
|
||||||
|
if entry.group:
|
||||||
|
return entry.group.name
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_entry_group_info(self, entry_name: str) -> GroupReference | None:
|
||||||
|
"""Get group of entry, with path."""
|
||||||
|
entry = await self._get_entry(entry_name)
|
||||||
|
if not entry:
|
||||||
|
raise InvalidSecretNameError("Invalid secret name or secret not found.")
|
||||||
|
if not entry.group:
|
||||||
|
return None
|
||||||
|
group = await self._get_group_by_id(entry.group.id)
|
||||||
|
group_tree = await self._build_group_tree(group)
|
||||||
|
return GroupReference(group_name=entry.group.name, path=group_tree.path)
|
||||||
|
|
||||||
|
async def _get_groups(
|
||||||
|
self, pattern: str | None = None, regex: bool = True, root_groups: bool = False
|
||||||
|
) -> list[Group]:
|
||||||
|
"""Get groups."""
|
||||||
|
statement = select(Group).options(
|
||||||
|
selectinload(Group.children), selectinload(Group.parent)
|
||||||
|
)
|
||||||
|
if pattern and regex:
|
||||||
|
statement = statement.where(Group.name.regexp_match(pattern))
|
||||||
|
elif pattern:
|
||||||
|
statement = statement.where(Group.name.contains(pattern))
|
||||||
|
if root_groups:
|
||||||
|
statement = statement.where(Group.parent_id == None)
|
||||||
|
results = await self.session.scalars(statement)
|
||||||
|
return list(results.all())
|
||||||
|
|
||||||
|
async def get_secret_groups(
|
||||||
|
self, pattern: str | None = None, regex: bool = True
|
||||||
|
) -> list[SecretGroup]:
|
||||||
|
"""Get secret groups, as a hierarcy."""
|
||||||
|
if pattern:
|
||||||
|
groups = await self._get_groups(pattern, regex)
|
||||||
|
else:
|
||||||
|
groups = await self._get_groups(root_groups=True)
|
||||||
|
|
||||||
|
secret_groups: list[SecretGroup] = []
|
||||||
|
for group in groups:
|
||||||
|
secret_group = await self._build_group_tree(group)
|
||||||
|
secret_groups.append(secret_group)
|
||||||
|
|
||||||
|
return secret_groups
|
||||||
|
|
||||||
|
async def get_secret_group_list(
|
||||||
|
self, pattern: str | None = None, regex: bool = True
|
||||||
|
) -> list[SecretGroup]:
|
||||||
|
"""Get secret group list."""
|
||||||
|
groups = await self._get_groups(pattern, regex)
|
||||||
|
return [(await self._build_group_tree(group)) for group in groups]
|
||||||
|
|
||||||
|
async def _get_group_by_id(self, id: uuid.UUID) -> Group:
|
||||||
|
"""Get group by ID."""
|
||||||
|
statement = (
|
||||||
|
select(Group)
|
||||||
|
.options(
|
||||||
|
selectinload(Group.parent),
|
||||||
|
selectinload(Group.children),
|
||||||
|
selectinload(Group.secrets),
|
||||||
|
)
|
||||||
|
.where(Group.id == id)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.session.scalars(statement)
|
||||||
|
return result.one()
|
||||||
|
|
||||||
|
async def _lookup_group(self, name_path: str) -> Group | None:
|
||||||
|
"""Lookup group by path."""
|
||||||
|
if "/" in name_path:
|
||||||
|
elements = parse_path(name_path)
|
||||||
|
return await self._get_group(elements.item, elements.parent)
|
||||||
|
return await self._get_group(name_path)
|
||||||
|
|
||||||
|
async def _get_group(
|
||||||
|
self, name: str, parent: str | None = None, exact_match: bool = False
|
||||||
|
) -> Group | None:
|
||||||
|
"""Get a group."""
|
||||||
|
statement = (
|
||||||
|
select(Group)
|
||||||
|
.options(
|
||||||
|
selectinload(Group.parent),
|
||||||
|
selectinload(Group.children),
|
||||||
|
selectinload(Group.secrets),
|
||||||
|
)
|
||||||
|
.where(Group.name == name)
|
||||||
|
)
|
||||||
|
if parent:
|
||||||
|
ParentGroup = aliased(Group)
|
||||||
|
statement = statement.join(ParentGroup, Group.parent).where(
|
||||||
|
ParentGroup.name == parent
|
||||||
|
)
|
||||||
|
elif exact_match:
|
||||||
|
statement = statement.where(Group.parent_id == None)
|
||||||
|
result = await self.session.scalars(statement)
|
||||||
|
return result.first()
|
||||||
|
|
||||||
|
async def get_secret_group(self, path: str) -> SecretGroup | None:
|
||||||
|
"""Get a secret group by path."""
|
||||||
|
elements = parse_path(path)
|
||||||
|
|
||||||
|
group_name = elements.item
|
||||||
|
parent_group = elements.parent
|
||||||
|
|
||||||
|
group = await self._get_group(group_name, parent_group)
|
||||||
|
if not group:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await self._build_group_tree(group)
|
||||||
|
|
||||||
|
async def get_ungrouped_secrets(self) -> list[str]:
|
||||||
|
"""Get ungrouped secrets."""
|
||||||
|
statement = (
|
||||||
|
select(ManagedSecret)
|
||||||
|
.where(ManagedSecret.is_deleted.is_not(True))
|
||||||
|
.where(ManagedSecret.group_id == None)
|
||||||
|
)
|
||||||
|
result = await self.session.scalars(statement)
|
||||||
|
secrets = result.all()
|
||||||
|
return [secret.name for secret in secrets]
|
||||||
|
|
||||||
|
async def add_group(
|
||||||
|
self,
|
||||||
|
name_or_path: str,
|
||||||
|
description: str | None = None,
|
||||||
|
parent_group: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Add a group."""
|
||||||
|
parent_id: uuid.UUID | None = None
|
||||||
|
group_name = name_or_path
|
||||||
|
if parent_group and name_or_path.startswith("/"):
|
||||||
|
raise InvalidGroupNameError(
|
||||||
|
"Path as name cannot be used if parent is also specified."
|
||||||
|
)
|
||||||
|
if name_or_path.startswith("/"):
|
||||||
|
elements = parse_path(name_or_path)
|
||||||
|
group_name = elements.item
|
||||||
|
parent_group = elements.parent
|
||||||
|
|
||||||
|
if parent_group:
|
||||||
|
if parent := (await self._lookup_group(parent_group)):
|
||||||
|
child_names = [child.name for child in parent.children]
|
||||||
|
if group_name in child_names:
|
||||||
|
raise InvalidGroupNameError(
|
||||||
|
"Parent group already has a group with this name."
|
||||||
|
)
|
||||||
|
parent_id = parent.id
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise InvalidGroupNameError(
|
||||||
|
"Invalid or non-existing parent group name."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
existing_group = await self._get_group(group_name)
|
||||||
|
if existing_group:
|
||||||
|
raise InvalidGroupNameError("A group with this name already exists.")
|
||||||
|
|
||||||
|
group = Group(
|
||||||
|
name=group_name,
|
||||||
|
description=description,
|
||||||
|
parent_id=parent_id,
|
||||||
|
)
|
||||||
|
self.session.add(group)
|
||||||
|
# We don't audit-log this operation.
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
async def update_group(
|
||||||
|
self, name_path: str, **params: Unpack[SecretUpdateParams]
|
||||||
|
) -> SecretGroup:
|
||||||
|
"""Perform a complete update of a group.
|
||||||
|
|
||||||
|
This allows a patch operation. Only keyword arguments added will be considered.
|
||||||
|
"""
|
||||||
|
group = await self._lookup_group(name_path)
|
||||||
|
|
||||||
|
if not group:
|
||||||
|
raise InvalidGroupNameError("Invalid or non-existing parent group name.")
|
||||||
|
if description := params.get("description"):
|
||||||
|
group.description = description
|
||||||
|
|
||||||
|
target_name = group.name
|
||||||
|
rename = False
|
||||||
|
if new_name := params.get("name"):
|
||||||
|
target_name = new_name
|
||||||
|
if target_name != group.name:
|
||||||
|
rename = True
|
||||||
|
|
||||||
|
parent_group: Group | None = None
|
||||||
|
move_to_root = False
|
||||||
|
if parent := params.get("parent"):
|
||||||
|
if parent == "/":
|
||||||
|
group.parent = None
|
||||||
|
move_to_root = True
|
||||||
|
if rename:
|
||||||
|
groups = await self._get_groups(root_groups=True)
|
||||||
|
root_names = [x.name for x in groups]
|
||||||
|
if target_name in root_names:
|
||||||
|
raise InvalidGroupNameError("Name is already in use")
|
||||||
|
|
||||||
|
else:
|
||||||
|
new_parent_group = await self._lookup_group(parent)
|
||||||
|
if not new_parent_group:
|
||||||
|
raise InvalidGroupNameError(
|
||||||
|
"Invalid or non-existing parent group name."
|
||||||
|
)
|
||||||
|
parent_group = new_parent_group
|
||||||
|
group.parent_id = new_parent_group.id
|
||||||
|
elif group.parent_id and not move_to_root:
|
||||||
|
parent_group = await self._get_group_by_id(group.parent_id)
|
||||||
|
|
||||||
|
if parent_group and rename and not move_to_root:
|
||||||
|
child_names = [child.name for child in parent_group.children]
|
||||||
|
if target_name in child_names:
|
||||||
|
raise InvalidGroupNameError(
|
||||||
|
f"Parent group {parent_group.name} already has a group with this name: {target_name}. Params: {params !r}"
|
||||||
|
)
|
||||||
|
group.name = target_name
|
||||||
|
|
||||||
|
self.session.add(group)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(group, ["parent"])
|
||||||
|
return await self._build_group_tree(group)
|
||||||
|
|
||||||
|
async def set_group_description(self, path: str, description: str) -> None:
|
||||||
|
"""Set group description."""
|
||||||
|
elements = parse_path(path)
|
||||||
|
group = await self._get_group(elements.item, elements.parent, True)
|
||||||
|
if not group:
|
||||||
|
raise InvalidGroupNameError("Invalid or non-existing group name.")
|
||||||
|
group.description = description
|
||||||
|
self.session.add(group)
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
async def set_secret_group(self, entry_name: str, group_name: str | None) -> None:
|
||||||
|
"""Move a secret to a group.
|
||||||
|
|
||||||
|
If group_name is None, the secret will be moved out of any group it may exist in.
|
||||||
|
"""
|
||||||
|
entry = await self._get_entry(entry_name)
|
||||||
|
if not entry:
|
||||||
|
raise InvalidSecretNameError("Invalid or non-existing secret.")
|
||||||
|
if group_name:
|
||||||
|
elements = parse_path(group_name)
|
||||||
|
group = await self._get_group(elements.item, elements.parent, True)
|
||||||
|
if not group:
|
||||||
|
raise InvalidGroupNameError("Invalid or non-existing group name.")
|
||||||
|
entry.group_id = group.id
|
||||||
|
else:
|
||||||
|
entry.group_id = None
|
||||||
|
|
||||||
|
self.session.add(entry)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.write_audit(
|
||||||
|
Operation.UPDATE,
|
||||||
|
"Secret group updated",
|
||||||
|
group_name=group_name or "ROOT",
|
||||||
|
secret_name=entry_name,
|
||||||
|
managed_secret=entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def move_group(self, path: str, parent_group: str | None) -> str:
|
||||||
|
"""Move group.
|
||||||
|
|
||||||
|
If parent_group is None, it will be moved to the root.
|
||||||
|
"""
|
||||||
|
LOG.info("Move group: %s => %s", path, parent_group)
|
||||||
|
elements = parse_path(path)
|
||||||
|
group = await self._get_group(elements.item, elements.parent, True)
|
||||||
|
if not group:
|
||||||
|
raise InvalidGroupNameError("Invalid or non-existing group name.")
|
||||||
|
|
||||||
|
parent_group_id: uuid.UUID | None = None
|
||||||
|
if parent_group:
|
||||||
|
db_parent_group = await self._lookup_group(parent_group)
|
||||||
|
if not db_parent_group:
|
||||||
|
raise InvalidGroupNameError("Invalid or non-existing parent group.")
|
||||||
|
parent_group_id = db_parent_group.id
|
||||||
|
|
||||||
|
group.parent_id = parent_group_id
|
||||||
|
|
||||||
|
self.session.add(group)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(group)
|
||||||
|
new_path = await self._get_group_path(group)
|
||||||
|
return new_path
|
||||||
|
|
||||||
|
async def delete_group(self, path: str) -> None:
|
||||||
|
"""Delete a group."""
|
||||||
|
elements = parse_path(path)
|
||||||
|
group = await self._get_group(elements.item, elements.parent, True)
|
||||||
|
if not group:
|
||||||
|
return
|
||||||
|
await self.session.delete(group)
|
||||||
|
|
||||||
|
await self.session.commit()
|
||||||
|
# We don't audit-log this operation currently, even though it indirectly
|
||||||
|
# may affect secrets.
|
||||||
|
|
||||||
|
async def delete_group_id(self, id: str | uuid.UUID) -> None:
|
||||||
|
"""Delete a group by ID."""
|
||||||
|
if isinstance(id, str):
|
||||||
|
id = uuid.UUID(id)
|
||||||
|
group = await self._get_group_by_id(id)
|
||||||
|
if not group:
|
||||||
|
raise InvalidGroupNameError("Invalid or non-existing group ID.")
|
||||||
|
await self.session.delete(group)
|
||||||
|
|
||||||
|
await self.session.commit()
|
||||||
|
|
||||||
|
async def _export_entries(self) -> list[SecretDataEntryExport]:
|
||||||
|
"""Export entries as a pydantic object."""
|
||||||
|
statement = (
|
||||||
|
select(ManagedSecret)
|
||||||
|
.options(selectinload(ManagedSecret.group))
|
||||||
|
.where(ManagedSecret.is_deleted.is_(False))
|
||||||
|
)
|
||||||
|
results = await self.session.scalars(statement)
|
||||||
|
entries: list[SecretDataEntryExport] = []
|
||||||
|
for entry in results.all():
|
||||||
|
group: str | None = None
|
||||||
|
if entry.group:
|
||||||
|
group = await self._get_group_path(entry.group)
|
||||||
|
secret = await self.get_secret(entry.name)
|
||||||
|
if not secret:
|
||||||
|
continue
|
||||||
|
data = SecretDataEntryExport(name=entry.name, secret=secret, group=group)
|
||||||
|
entries.append(data)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
async def _export_groups(self) -> list[SecretDataGroupExport]:
|
||||||
|
"""Export groups as pydantic objects."""
|
||||||
|
groups = await self.get_secret_group_list()
|
||||||
|
entries = [
|
||||||
|
SecretDataGroupExport(
|
||||||
|
name=group.name,
|
||||||
|
path=group.path,
|
||||||
|
description=group.description,
|
||||||
|
)
|
||||||
|
for group in groups
|
||||||
|
]
|
||||||
|
return entries
|
||||||
|
|
||||||
|
async def export_secrets(self) -> SecretDataExport:
|
||||||
|
"""Export the managed secrets as a pydantic object."""
|
||||||
|
entries = await self._export_entries()
|
||||||
|
groups = await self._export_groups()
|
||||||
|
return SecretDataExport(entries=entries, groups=groups)
|
||||||
|
|
||||||
|
async def export_secrets_json(self) -> str:
|
||||||
|
"""Export secrets as JSON."""
|
||||||
|
export = await self.export_secrets()
|
||||||
|
return export.model_dump_json(indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def get_managed_private_key(
|
||||||
|
settings: AdminServerSettings,
|
||||||
|
filename: str = KEY_FILENAME,
|
||||||
|
regenerate: bool = False,
|
||||||
|
) -> rsa.RSAPrivateKey:
|
||||||
|
"""Load our private key."""
|
||||||
|
keyfile = Path(filename)
|
||||||
|
if settings.password_manager_directory:
|
||||||
|
keyfile = settings.password_manager_directory / filename
|
||||||
|
if not keyfile.exists():
|
||||||
|
_initial_key_setup(settings, keyfile)
|
||||||
|
setup_password_manager(settings, keyfile, regenerate)
|
||||||
|
return load_private_key(str(keyfile.absolute()), password=settings.secret_key)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_password_manager(
|
||||||
|
settings: AdminServerSettings, filename: Path, regenerate: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""Setup password manager."""
|
||||||
|
if filename.exists() and not regenerate:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not settings.secret_key:
|
||||||
|
raise RuntimeError("Error: Could not load secret key from environment.")
|
||||||
|
create_private_rsa_key(filename, password=settings.secret_key)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def create_manager_client(
|
||||||
|
backend: SshecretBackend, public_key: rsa.RSAPublicKey
|
||||||
|
) -> Client:
|
||||||
|
"""Create the manager client."""
|
||||||
|
public_key_string = generate_public_key_string(public_key)
|
||||||
|
new_client = await backend.create_system_client(
|
||||||
|
"AdminPasswordManager",
|
||||||
|
public_key_string,
|
||||||
|
)
|
||||||
|
return new_client
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def password_manager_context(
|
||||||
|
settings: AdminServerSettings, username: str, origin: str
|
||||||
|
) -> AsyncIterator[AsyncSecretContext]:
|
||||||
|
"""Start a context for the password manager."""
|
||||||
|
audit_context_data = ClientAuditData(username=username, origin=origin)
|
||||||
|
session_manager = DatabaseSessionManager(settings.async_db_url)
|
||||||
|
backend = SshecretBackend(str(settings.backend_url), settings.backend_token)
|
||||||
|
private_key = get_managed_private_key(settings)
|
||||||
|
async with session_manager.session() as session:
|
||||||
|
# Check if there is a client_id stored already.
|
||||||
|
query = select(PasswordDB).where(PasswordDB.id == 1)
|
||||||
|
result = await session.scalars(query)
|
||||||
|
password_db = result.first()
|
||||||
|
if not password_db:
|
||||||
|
password_db = PasswordDB(id=1)
|
||||||
|
session.add(password_db)
|
||||||
|
await session.flush()
|
||||||
|
if not password_db.client_id:
|
||||||
|
manager_client = await create_manager_client(
|
||||||
|
backend, private_key.public_key()
|
||||||
|
)
|
||||||
|
password_db.client_id = manager_client.id
|
||||||
|
session.add(password_db)
|
||||||
|
await session.commit()
|
||||||
|
else:
|
||||||
|
manager_client = await backend.get_client(
|
||||||
|
("id", str(password_db.client_id))
|
||||||
|
)
|
||||||
|
if not manager_client:
|
||||||
|
raise SecretManagerError("Error: Could not fetch system client.")
|
||||||
|
|
||||||
|
context = AsyncSecretContext(
|
||||||
|
private_key, manager_client, session, backend, audit_context_data
|
||||||
|
)
|
||||||
|
yield context
|
||||||
|
|
||||||
|
|
||||||
|
def setup_private_key(
|
||||||
|
settings: AdminServerSettings,
|
||||||
|
filename: str = KEY_FILENAME,
|
||||||
|
regenerate: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Setup secret manager private key."""
|
||||||
|
keyfile = Path(filename)
|
||||||
|
if settings.password_manager_directory:
|
||||||
|
keyfile = settings.password_manager_directory / filename
|
||||||
|
_initial_key_setup(settings, keyfile, regenerate)
|
||||||
|
|
||||||
|
|
||||||
|
def _initial_key_setup(
|
||||||
|
settings: AdminServerSettings,
|
||||||
|
keyfile: Path,
|
||||||
|
regenerate: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Set up initial keys."""
|
||||||
|
if keyfile.exists() and not regenerate:
|
||||||
|
return False
|
||||||
|
|
||||||
|
assert (
|
||||||
|
settings.secret_key is not None
|
||||||
|
), "Error: Could not load a secret key from environment."
|
||||||
|
create_private_rsa_key(keyfile, password=settings.secret_key)
|
||||||
|
return True
|
||||||
@ -1,25 +0,0 @@
|
|||||||
"""SSH Server settings."""
|
|
||||||
|
|
||||||
from pydantic import AnyHttpUrl, Field
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_LISTEN_PORT = 8822
|
|
||||||
|
|
||||||
DEFAULT_DATABASE = "sqlite:///ssh_admin.db"
|
|
||||||
|
|
||||||
|
|
||||||
class AdminServerSettings(BaseSettings):
|
|
||||||
"""Server Settings."""
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=".admin.env", env_prefix="sshecret_admin_", secrets_dir="/var/run"
|
|
||||||
)
|
|
||||||
|
|
||||||
backend_url: AnyHttpUrl = Field(alias="sshecret_backend_url")
|
|
||||||
backend_token: str
|
|
||||||
listen_address: str = Field(default="")
|
|
||||||
secret_key: str
|
|
||||||
port: int = DEFAULT_LISTEN_PORT
|
|
||||||
admin_db: str = Field(default=DEFAULT_DATABASE)
|
|
||||||
debug: bool = False
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
@source "../node_modules/flowbite";
|
|
||||||
@source "../node_modules/flowbite-datepicker";
|
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--color-primary-50: #eff6ff;
|
|
||||||
--color-primary-100: #dbeafe;
|
|
||||||
--color-primary-200: #bfdbfe;
|
|
||||||
--color-primary-300: #93c5fd;
|
|
||||||
--color-primary-400: #60a5fa;
|
|
||||||
--color-primary-500: #3b82f6;
|
|
||||||
--color-primary-600: #2563eb;
|
|
||||||
--color-primary-700: #1d4ed8;
|
|
||||||
--color-primary-800: #1e40af;
|
|
||||||
--color-primary-900: #1e3a8a;
|
|
||||||
|
|
||||||
--font-sans: "Inter", "ui-sans-serif", "system-ui", "-apple-system",
|
|
||||||
"system-ui", "Segoe UI", "Roboto", "Helvetica Neue", "Arial",
|
|
||||||
"Noto Sans", "sans-serif", "Apple Color Emoji", "Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol", "Noto Color Emoji";
|
|
||||||
--font-body: "Inter", "ui-sans-serif", "system-ui", "-apple-system",
|
|
||||||
"system-ui", "Segoe UI", "Roboto", "Helvetica Neue", "Arial",
|
|
||||||
"Noto Sans", "sans-serif", "Apple Color Emoji", "Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol", "Noto Color Emoji";
|
|
||||||
--font-mono: "ui-monospace", "SFMono-Regular", "Menlo", "Monaco",
|
|
||||||
"Consolas", "Liberation Mono", "Courier New", "monospace";
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,3 +0,0 @@
|
|||||||
/* PrismJS 1.30.0
|
|
||||||
https://prismjs.com/download.html#themes=prism&languages=markup+css+json */
|
|
||||||
code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}
|
|
||||||
@ -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
@ -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,
|
|
||||||
// );
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
@ -1,31 +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"
|
|
||||||
>
|
|
||||||
{{ entry.subsystem }}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
|
||||||
>
|
|
||||||
<pre><code class="language-json">
|
|
||||||
{%- set entry_object = ({"object": entry.object, "object_id": entry.object_id, "client_id": entry.client_id, "client_name": entry.client_name}) -%}
|
|
||||||
{{- entry_object | tojson(indent=2) -}}</code></pre>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
|
||||||
>
|
|
||||||
{{ entry.message }}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="p-4 text-sm font-normal text-gray-500 whitespace-nowrap dark:text-gray-400"
|
|
||||||
>
|
|
||||||
{{ entry.origin }}
|
|
||||||
</td>
|
|
||||||
@ -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 %}
|
|
||||||
@ -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"
|
|
||||||
>
|
|
||||||
Timestamp
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
|
||||||
>
|
|
||||||
Subsystem
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
|
||||||
>
|
|
||||||
Object
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
|
||||||
>
|
|
||||||
Message
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
|
||||||
>
|
|
||||||
Origin
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody
|
|
||||||
class="bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700"
|
|
||||||
>
|
|
||||||
{% for entry in entries %} {% include 'audit/entry.html.j2' %} {%
|
|
||||||
endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% include 'audit/pagination.html.j2' %}
|
|
||||||
</div>
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
<div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<div class="inline-block min-w-full align-middle">
|
|
||||||
<div class="overflow-hidden shadow">
|
|
||||||
<table
|
|
||||||
class="min-w-full divide-y divide-gray-200 table-fixed dark:divide-gray-600"
|
|
||||||
>
|
|
||||||
<thead class="bg-gray-100 dark:bg-gray-700">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
|
||||||
>
|
|
||||||
ID
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
|
||||||
>
|
|
||||||
Operation
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
|
||||||
>
|
|
||||||
Client Name
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
|
||||||
>
|
|
||||||
Message
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
scope="col"
|
|
||||||
class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400"
|
|
||||||
>
|
|
||||||
Origin
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody
|
|
||||||
class="bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700"
|
|
||||||
>
|
|
||||||
{% for entry in entries %} {% include 'audit/entry.html.j2' %} {%
|
|
||||||
endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% include 'audit/pagination.html.j2' %}
|
|
||||||
</div>
|
|
||||||
@ -1,67 +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
|
|
||||||
<span class="font-semibold text-gray-900 dark:text-white">{{page_info.first }}-{{ page_info.last}}</span> of
|
|
||||||
<span class="font-semibold text-gray-900 dark:text-white"
|
|
||||||
>{{ page_info.total }}</span
|
|
||||||
></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="flex space-x-1">
|
|
||||||
|
|
||||||
<button
|
|
||||||
{% if page_info.page == 1 %}
|
|
||||||
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-gray-900 bg-white border border-gray-300 rounded hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 transition duration-200 ease"
|
|
||||||
disabled=""
|
|
||||||
{% else %}
|
|
||||||
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800 transition duration-200 ease"
|
|
||||||
hx-get="/audit/page/{{ page_info.page - 1 }}"
|
|
||||||
hx-target="#auditContent"
|
|
||||||
hx-push-url="true"
|
|
||||||
{% endif %}
|
|
||||||
>
|
|
||||||
Prev
|
|
||||||
</button>
|
|
||||||
{% for n in range(page_info.total_pages) %}
|
|
||||||
{% set p = n + 1 %}
|
|
||||||
{% if p == page_info.page %}
|
|
||||||
<button
|
|
||||||
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800 transition duration-200 ease">
|
|
||||||
|
|
||||||
{{ p }}
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<button
|
|
||||||
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-gray-900 bg-white border border-gray-300 rounded hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 transition duration-200 ease"
|
|
||||||
|
|
||||||
hx-get="/audit/page/{{ p }}"
|
|
||||||
hx-target="#auditContent"
|
|
||||||
hx-push-url="true"
|
|
||||||
>
|
|
||||||
{{ p }}
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<button
|
|
||||||
{% if page_info.page < page_info.total_pages %}
|
|
||||||
|
|
||||||
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-white bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800 transition duration-200 ease"
|
|
||||||
|
|
||||||
hx-get="/audit/page/{{ page_info.page + 1 }}"
|
|
||||||
hx-target="#auditContent"
|
|
||||||
hx-push-url="true"
|
|
||||||
{% else %}
|
|
||||||
class="px-3 py-1 min-w-9 min-h-9 text-sm font-normal text-gray-900 bg-white border border-gray-300 rounded hover:bg-gray-100 focus:ring-4 focus:ring-primary-300 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 transition duration-200 ease"
|
|
||||||
disabled=""
|
|
||||||
{% endif %}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -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"
|
|
||||||
data-drawer-target="drawer-update-client-{{ client.id }}"
|
|
||||||
data-drawer-show="drawer-update-client-{{ client.id }}"
|
|
||||||
aria-controls="drawer-update-client-{{ client.id }}"
|
|
||||||
data-drawer-placement="right"
|
|
||||||
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 mr-2"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="deleteClientButton"
|
|
||||||
data-drawer-target="drawer-delete-client-{{ client.id }}"
|
|
||||||
data-drawer-show="drawer-delete-client-{{ client.id }}"
|
|
||||||
aria-controls="drawer-delete-client-{{ client.id }}"
|
|
||||||
data-drawer-placement="right"
|
|
||||||
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-red-700 rounded-lg hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 mr-2"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Delete item
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@ -1,145 +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">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="name"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
|
||||||
>Name</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
id="name"
|
|
||||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
|
||||||
placeholder="Client name"
|
|
||||||
required=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="description"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
|
||||||
>Description</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="description"
|
|
||||||
id="description"
|
|
||||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
|
||||||
placeholder="Client description"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="sources"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
|
||||||
>Allowed subnets or IPs</label
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
id="helper-text-explanation"
|
|
||||||
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
|
|
||||||
>
|
|
||||||
Separate multiple entries with comma.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="sources"
|
|
||||||
id="sources"
|
|
||||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
|
||||||
placeholder="0.0.0.0/0"
|
|
||||||
value="0.0.0.0/0"
|
|
||||||
hx-post="/clients/validate/source"
|
|
||||||
hx-target="#clientSourceValidation"
|
|
||||||
/>
|
|
||||||
<span id="clientSourceValidation"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="public_key"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
|
||||||
>Public Key</label
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
id="public_key"
|
|
||||||
name="public_key"
|
|
||||||
rows="4"
|
|
||||||
class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
|
||||||
placeholder="Enter RSA SSH Public Key here"
|
|
||||||
hx-post="/clients/validate/public_key"
|
|
||||||
hx-target="#clientPublicKeyValidation"
|
|
||||||
></textarea>
|
|
||||||
<span id="clientPublicKeyValidation"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="bottom-0 left-0 flex justify-center w-full pb-4 space-x-4 md:px-4 md:absolute"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
|
||||||
>
|
|
||||||
Add Client
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-drawer-dismiss="drawer-create-client-default"
|
|
||||||
aria-controls="drawer-create-client-default"
|
|
||||||
class="inline-flex w-full justify-center text-gray-500 items-center bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-primary-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="w-5 h-5 -ml-1 sm:mr-1"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@ -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>
|
|
||||||
@ -1,173 +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"
|
|
||||||
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="w-5 h-5 mr-1 -ml-1"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
<template>
|
|
||||||
{% include '/clients/inner.html.j2' %}
|
|
||||||
</template>
|
|
||||||
@ -1 +0,0 @@
|
|||||||
<p class="mt-2 text-sm text-red-600 dark:text-red-500"><span class="font-medium">Invalid value. </span> {{explanation}}.</p>
|
|
||||||
@ -1 +0,0 @@
|
|||||||
<span></span>
|
|
||||||
@ -1,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 %}
|
|
||||||
@ -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">
|
|
||||||
<thead class="bg-gray-100 dark:bg-gray-700">
|
|
||||||
<tr>
|
|
||||||
|
|
||||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
|
||||||
Name
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
|
||||||
ID
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
|
||||||
Description
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
|
||||||
Number of secrets allocated
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
|
||||||
Allowed Sources
|
|
||||||
</th>
|
|
||||||
<th scope="col" class="p-4 text-xs font-medium text-left text-gray-500 uppercase dark:text-gray-400">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700">
|
|
||||||
{% for client in clients %}
|
|
||||||
{% include '/clients/client.html.j2'%}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% for client in clients %}
|
|
||||||
{% include '/clients/drawer_client_update.html.j2' %}
|
|
||||||
{% include '/clients/drawer_client_delete.html.j2' %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
@ -1,10 +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"
|
|
||||||
>
|
|
||||||
<h1 class="mb-4 text-4xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white">Welcome to Sshecret</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@ -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>
|
|
||||||
@ -1 +0,0 @@
|
|||||||
<!-- todo -->
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="description" content="{{ page_description }}" />
|
|
||||||
|
|
||||||
<title>{{page_title}}</title>
|
|
||||||
|
|
||||||
{% include '/dashboard/_stylesheet.html' %} {% include
|
|
||||||
'/dashboard/_favicons.html' %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
|
|
||||||
if (
|
|
||||||
localStorage.getItem("color-theme") === "dark" ||
|
|
||||||
(!("color-theme" in localStorage) &&
|
|
||||||
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
|
||||||
) {
|
|
||||||
document.documentElement.classList.add("dark");
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove("dark");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@ -1,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>
|
|
||||||
@ -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"
|
|
||||||
/>
|
|
||||||
@ -1,47 +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="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white" role="menuitem">Logout</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
<aside
|
|
||||||
id="sidebar"
|
|
||||||
class="fixed top-0 left-0 z-20 flex flex-col flex-shrink-0 hidden w-64 h-full pt-16 font-normal duration-75 lg:flex transition-width"
|
|
||||||
aria-label="Sidebar"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="relative flex flex-col flex-1 min-h-0 pt-0 bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col flex-1 pt-5 pb-4 overflow-y-auto">
|
|
||||||
<div
|
|
||||||
class="flex-1 px-3 space-y-1 bg-white divide-y divide-gray-200 dark:bg-gray-800 dark:divide-gray-700"
|
|
||||||
>
|
|
||||||
<ul class="pb-2 space-y-2">
|
|
||||||
<!-- This is the menu -->
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="flex items-center p-2 text-base text-gray-900 rounded-lg hover:bg-gray-100 group dark:text-gray-200 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-6 h-6 text-gray-500 transition duration-75 group-hover:text-gray-900 dark:text-gray-400 dark:group-hover:text-white"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"></path>
|
|
||||||
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="ml-3" sidebar-toggle-item>Dashboard</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/clients"
|
|
||||||
class="flex items-center p-2 text-base text-gray-900 rounded-lg hover:bg-gray-100 group dark:text-gray-200 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-6 h-6 text-gray-800 dark:text-white"
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M7 6H5m2 3H5m2 3H5m2 3H5m2 3H5m11-1a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2M7 3h11a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Zm8 7a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="ml-3" sidebar-toggle-item>Clients</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/secrets"
|
|
||||||
class="flex items-center p-2 text-base text-gray-900 rounded-lg hover:bg-gray-100 group dark:text-gray-200 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-6 h-6 text-gray-800 dark:text-white"
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M18 9V4a1 1 0 0 0-1-1H8.914a1 1 0 0 0-.707.293L4.293 7.207A1 1 0 0 0 4 7.914V20a1 1 0 0 0 1 1h6M9 3v4a1 1 0 0 1-1 1H4m11 13a11.426 11.426 0 0 1-3.637-3.99A11.139 11.139 0 0 1 10 11.833L15 10l5 1.833a11.137 11.137 0 0 1-1.363 5.176A11.425 11.425 0 0 1 15.001 21Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="ml-3" sidebar-toggle-item>Secrets</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="/audit"
|
|
||||||
class="flex items-center p-2 text-base text-gray-900 rounded-lg hover:bg-gray-100 group dark:text-gray-200 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-6 h-6 text-gray-800 dark:text-white"
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 8h6m-6 4h6m-6 4h6M6 3v18l2-2 2 2 2-2 2 2 2-2 2 2V3l-2 2-2-2-2 2-2-2-2 2-2-2Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="ml-3" sidebar-toggle-item>Audit</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
{% extends "/dashboard/_base.html" %} {% block content %}
|
|
||||||
|
|
||||||
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
|
|
||||||
<div class="pb-4 bg-white dark:bg-gray-900">
|
|
||||||
<label for="table-search" class="sr-only">Search</label>
|
|
||||||
<div class="relative mt-1">
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 rtl:inset-r-0 start-0 flex items-center ps-3 pointer-events-none"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 text-gray-500 dark:text-gray-400"
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="table-search"
|
|
||||||
class="block pt-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg w-80 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
||||||
placeholder="Search for items"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<table
|
|
||||||
class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400"
|
|
||||||
>
|
|
||||||
<thead
|
|
||||||
class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"
|
|
||||||
>
|
|
||||||
<tr>
|
|
||||||
<th scope="col" class="px-6 py-3">Client Name</th>
|
|
||||||
<th scope="col" class="px-6 py-3">Description</th>
|
|
||||||
<th scope="col" class="px-6 py-3">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for client in clients %}
|
|
||||||
<tr
|
|
||||||
class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600"
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
scope="row"
|
|
||||||
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"
|
|
||||||
>
|
|
||||||
{{ client.name }}
|
|
||||||
</th>
|
|
||||||
<td class="px-6 py-4">{{ client.description }}</td>
|
|
||||||
<td class="px-6 py-4">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
|
|
||||||
>Edit</a
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
<p class="mt-2 text-sm text-green-600 dark:text-red-500">
|
|
||||||
<span class="font-medium">{{ message }}</span>
|
|
||||||
</p>
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
<p class="mt-2 text-sm text-green-600 dark:text-green-500">
|
|
||||||
<span class="font-medium">{{ message }}</span>
|
|
||||||
</p>
|
|
||||||
@ -1,55 +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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>Password</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2.5 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
{% for client in clients %}
|
|
||||||
<option value="{{ client.id }}">{{ client.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
@ -1,155 +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">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="name"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
|
||||||
>Name</label
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="name"
|
|
||||||
id="name"
|
|
||||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
|
||||||
placeholder="Secret name"
|
|
||||||
required=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="value"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
|
||||||
>Secret Value</label
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
id="helper-text-explanation"
|
|
||||||
class="mt-2 text-sm text-gray-500 dark:text-gray-400"
|
|
||||||
>
|
|
||||||
Enter the secret string here.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="value"
|
|
||||||
id="secretValueInput"
|
|
||||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
|
||||||
placeholder="Your secret string here"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="auto_generate"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
<label class="inline-flex items-center cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="auto_generate"
|
|
||||||
id="autoGenerateCheckbox"
|
|
||||||
class="sr-only peer"
|
|
||||||
hx-on:change="document.getElementById('secretValueInput').disabled = this.checked;
|
|
||||||
if (this.checked) { document.getElementById('secretValueInput').value = '' }"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<span
|
|
||||||
class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300"
|
|
||||||
>Auto-generate secret</span
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="clients"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
|
||||||
>Clients</label
|
|
||||||
>
|
|
||||||
|
|
||||||
<select
|
|
||||||
multiple="multiple"
|
|
||||||
id="clients"
|
|
||||||
name="clients"
|
|
||||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
|
||||||
>
|
|
||||||
<option selected="selected">Select clients to assign the secret to</option>
|
|
||||||
{% for client in clients %}
|
|
||||||
<option value="{{ client.id }}">{{ client.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="bottom-0 left-0 flex justify-center w-full pb-4 space-x-4 md:px-4 md:absolute"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
|
||||||
>
|
|
||||||
Add Secret
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-drawer-dismiss="drawer-create-secret-default"
|
|
||||||
aria-controls="drawer-create-secret-default"
|
|
||||||
class="inline-flex w-full justify-center text-gray-500 items-center bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-primary-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="w-5 h-5 -ml-1 sm:mr-1"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@ -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 %}
|
|
||||||
@ -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 %}
|
|
||||||
@ -1,119 +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"
|
|
||||||
>{{ client.name }}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center p-1 ms-2 text-sm text-gray-400 bg-transparent rounded-xs hover:bg-gray-200 hover:text-gray-900 dark:hover:bg-gray-600 dark:hover:text-gray-300"
|
|
||||||
aria-label="Remove"
|
|
||||||
hx-delete="/secrets/{{ secret.name }}/clients/{{ client.id }}"
|
|
||||||
hx-target="#secretsContent"
|
|
||||||
hx-confirm="Remove client {{ client.name }} from secret {{secret.name}}?"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-2 h-2"
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 14 14"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Remove badge</span>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<form
|
|
||||||
class="space-y-4"
|
|
||||||
hx-post="/secrets/{{ secret.name }}/clients/"
|
|
||||||
hx-target="#secretsContent"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
|
|
||||||
Give client access
|
|
||||||
</h3>
|
|
||||||
<label
|
|
||||||
for="client"
|
|
||||||
class="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
|
|
||||||
>Client
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="client"
|
|
||||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
|
|
||||||
>
|
|
||||||
<option selected="selected">
|
|
||||||
Select clients to assign the secret to
|
|
||||||
</option>
|
|
||||||
{% for client in clients %}
|
|
||||||
{% if client.id|string not in secret.clients|map(attribute='id')|list %}
|
|
||||||
<option value="{{ client.id }}">{{ client.name }}</option>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="text-white w-full justify-center bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
|
||||||
>
|
|
||||||
Give Access
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,71 +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"
|
|
||||||
>
|
|
||||||
{% if secret.clients %}
|
|
||||||
{% for client in secret.clients %}
|
|
||||||
<span class="bg-gray-100 text-gray-800 text-xs font-medium inline-flex items-center px-2.5 py-0.5 rounded-sm me-2 dark:bg-gray-700 dark:text-gray-400 border border-gray-500 ">
|
|
||||||
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path fill-rule="evenodd" d="M12 4a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm-2 9a4 4 0 0 0-4 4v1a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-1a4 4 0 0 0-4-4h-4Z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
{{ client.name }}
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<p class="italic font-small">No clients</p>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="p-4 space-x-2 whitespace-nowrap">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-modal-target="client-secret-modal-{{secret.name}}" data-modal-toggle="client-secret-modal-{{ secret.name }}"
|
|
||||||
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white rounded-lg bg-primary-700 hover:bg-primary-800 focus:ring-4 focus:ring-primary-300 dark:bg-primary-600 dark:hover:bg-primary-700 dark:focus:ring-primary-800"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 mr-2"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Manage Client Access
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center px-3 py-2 text-sm font-medium text-center text-white bg-red-700 rounded-lg hover:bg-red-800 focus:ring-4 focus:ring-red-300 dark:focus:ring-red-900"
|
|
||||||
hx-delete="/secrets/{{ secret.name }}"
|
|
||||||
hx-confirm="Are you sure you want to delete the secret {{ secret.name }}?"
|
|
||||||
hx-target="#secretsContent"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 mr-2"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
Delete item
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
|
||||||
/>
|
|
||||||
<title>{{ page_title }}</title>
|
|
||||||
<meta name="description" content="{{ page_description }}" />
|
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="{{ url_for('static', path='css/main.css') }}"
|
|
||||||
type="text/css"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<link
|
|
||||||
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
|
||||||
/>
|
|
||||||
<title>{{ page_title }}</title>
|
|
||||||
<meta name="description" content="{{ page_description }}" />
|
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="{{ url_for('static', path='css/main.css') }}"
|
|
||||||
type="text/css"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<link
|
|
||||||
href="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.css"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<link
|
|
||||||
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="bg-white border-gray-200 dark:bg-gray-900">
|
|
||||||
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
|
|
||||||
<div class="flex items-center md:order-2 space-x-3 md:space-x-0 rtl:space-x-reverse">
|
|
||||||
<button type="button" class="flex text-sm bg-gray-800 rounded-full md:me-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600" id="user-menu-button" aria-expanded="false" data-dropdown-toggle="user-dropdown" data-dropdown-placement="bottom">
|
|
||||||
<span class="sr-only">Open user menu</span>
|
|
||||||
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="50" cy="50" r="50" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow-sm dark:bg-gray-700 dark:divide-gray-600" id="user-dropdown">
|
|
||||||
<div class="px-4 py-3">
|
|
||||||
<span class="block text-sm text-gray-900 dark:text-white">{{ user }}</span>
|
|
||||||
</div>
|
|
||||||
<ul class="py-2" aria-labelledby="user-menu-button">
|
|
||||||
<li>
|
|
||||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Change Password</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Log out</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<button data-drawer-target="default-sidebar" data-drawer-toggle="default-sidebar" aria-controls="default-sidebar" type="button" class="inline-flex items-center p-2 mt-2 ms-3 text-sm text-gray-500 rounded-lg sm:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600">
|
|
||||||
<span class="sr-only">Open sidebar</span>
|
|
||||||
<svg class="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path clip-rule="evenodd" fill-rule="evenodd" d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
|
|
||||||
<aside id="default-sidebar" class="fixed top-0 left-0 z-40 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0" aria-label="Sidebar">
|
|
||||||
<div class="h-full px-3 py-4 overflow-y-auto bg-gray-50 dark:bg-gray-800">
|
|
||||||
<a href="/" class="flex items-center ps-2.5 mb-5">
|
|
||||||
<img src="{{ url_for('static', path='logo.svg') }}" class="h-6 me-3 sm:h-7" alt="Sshecret Logo" />
|
|
||||||
<span class="self-center text-xl font-semibold whitespace-nowrap dark:text-white">Sshecret</span>
|
|
||||||
</a>
|
|
||||||
<ul class="space-y-2 font-medium">
|
|
||||||
<li>
|
|
||||||
<a href="#" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
|
||||||
<svg class="shrink-0 w-5 h-5 text-gray-500 transition duration-75 dark:text-gray-400 group-hover:text-gray-900 dark:group-hover:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12a1 1 0 0 0-1 1v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4a1 1 0 0 0-1-1M5 12h14M5 12a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1m-2 3h.01M14 15h.01M17 9h.01M14 9h.01"/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<span class="ms-3">Clients</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#" class="flex items-center p-2 text-gray-900 rounded-lg dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 group">
|
|
||||||
<svg class="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9V4a1 1 0 0 0-1-1H8.914a1 1 0 0 0-.707.293L4.293 7.207A1 1 0 0 0 4 7.914V20a1 1 0 0 0 1 1h6M9 3v4a1 1 0 0 1-1 1H4m11 13a11.426 11.426 0 0 1-3.637-3.99A11.139 11.139 0 0 1 10 11.833L15 10l5 1.833a11.137 11.137 0 0 1-1.363 5.176A11.425 11.425 0 0 1 15.001 21Z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="ms-3">Secrets</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div class="p-4 sm:ml-64">
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</div>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/flowbite@3.1.2/dist/flowbite.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
|
||||||
/>
|
|
||||||
<title>{{ page_title }}</title>
|
|
||||||
<meta name="description" content="{{ page_description }}" />
|
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="{{ url_for('static', path='css/main.css') }}"
|
|
||||||
type="text/css"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<link
|
|
||||||
href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div
|
|
||||||
class="fixed left-0 top-0 w-64 h-full bg-[#f8f4f3] p-4 z-50 sidebar-menu transition-transform"
|
|
||||||
>
|
|
||||||
<a href="#" class="flex items-center pb-4 border-b border-b-gray-800">
|
|
||||||
<h2 class="font-bold text-2xl">
|
|
||||||
SSHecret
|
|
||||||
<span class="bg-[#f84525] text-white px-2 rounded-md">Admin</span>
|
|
||||||
</h2>
|
|
||||||
</a>
|
|
||||||
<!-- MENU -->
|
|
||||||
<ul class="mt4">
|
|
||||||
<span class="text-gray-400 font-bold">Admin</span>
|
|
||||||
<li class="mb-1 group">
|
|
||||||
<a
|
|
||||||
href="/clients"
|
|
||||||
class="flex font-semibold items-center py-2 px-4 text-gray-900 hover:bg-gray-950 hover:text-gray-100 rounded-md group-[.active]:bg-gray-800 group-[.active]:text-white group-[.selected]:bg-gray-950 group-[.selected]:text-gray-100"
|
|
||||||
>
|
|
||||||
<i class="ri-server-line mr-3 text-lg"></i>
|
|
||||||
<span class="text-sm">Clients</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="mb-1 group">
|
|
||||||
<a
|
|
||||||
href="/secrets"
|
|
||||||
class="flex font-semibold items-center py-2 px-4 text-gray-900 hover:bg-gray-950 hover:text-gray-100 rounded-md group-[.active]:bg-gray-800 group-[.active]:text-white group-[.selected]:bg-gray-950 group-[.selected]:text-gray-100"
|
|
||||||
>
|
|
||||||
<i class="ri-safe-2-line mr-3 text-lg"></i>
|
|
||||||
<span class="text-sm">Secrets</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="fixed top-0 left-0 w-full h-full bg-black/50 z-40 md:hidden sidebar-overlay"
|
|
||||||
></div>
|
|
||||||
<main
|
|
||||||
class="w-full md:w-[calc(100%-256px)] md:ml-64 bg-gray-200 min-h-screen transition-all main"
|
|
||||||
>
|
|
||||||
<!-- navbar -->
|
|
||||||
<div
|
|
||||||
class="py-2 px-6 bg-[#f8f4f3] flex items-center shadow-md shadow-black/5 sticky top-0 left-0 z-30"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="text-lg text-gray-900 font-semibold sidebar-toggle"
|
|
||||||
>
|
|
||||||
<i class="ri-menu-line"></i>
|
|
||||||
</button>
|
|
||||||
<ul class="ml-auto flex items-center">
|
|
||||||
<li class="dropdown ml-3">
|
|
||||||
<button type="button" class="dropdown-toggle flex items-center">
|
|
||||||
<div class="flex-shrink-0 w-10 h-10 relative">
|
|
||||||
<div
|
|
||||||
class="p-1 bg-white rounded-full focus:outline-none focus:ring"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="top-0 left-7 absolute w-3 h-3 bg-lime-400 border-2 border-white rounded-full animate-ping"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="top-0 left-7 absolute w-3 h-3 bg-lime-500 border-2 border-white rounded-full"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-2 md:block text-left">
|
|
||||||
<h2 class="text-sm font-semibold text-gray-800">
|
|
||||||
{{ user.username }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<ul
|
|
||||||
class="dropdown-menu shadow-md shadow-black/5 z-30 hidden py-1.5 rounded-md bg-white border border-gray-100 w-full max-w-[140px]"
|
|
||||||
>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="flex items-center text-[13px] py-1.5 px-4 text-gray-600 hover:text-[#f84525] hover:bg-gray-50"
|
|
||||||
>Profile</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="flex items-center text-[13px] py-1.5 px-4 text-gray-600 hover:text-[#f84525] hover:bg-gray-50"
|
|
||||||
>Settings</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<form method="POST" action="">
|
|
||||||
<a
|
|
||||||
role="menuitem"
|
|
||||||
class="flex items-center text-[13px] py-1.5 px-4 text-gray-600 hover:text-[#f84525] hover:bg-gray-50 cursor-pointer"
|
|
||||||
onclick="event.preventDefault();
|
|
||||||
this.closest('form').submit();"
|
|
||||||
>
|
|
||||||
Log Out
|
|
||||||
</a>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">{% block content %}{% endblock %}</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
{% extends "/shared/_base.html" %} {% block content %}
|
|
||||||
|
|
||||||
<h1>Hooray!</h1>
|
|
||||||
<p>It worked!</p>
|
|
||||||
<p>Welcome, {{ user.username }}</p>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
"""Testing helper functions."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import bcrypt
|
|
||||||
|
|
||||||
from sqlalchemy import Engine
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from .auth_models import User
|
|
||||||
|
|
||||||
|
|
||||||
def get_test_user_details() -> tuple[str, str]:
|
|
||||||
"""Resolve testing user."""
|
|
||||||
test_user = os.getenv("SSHECRET_TEST_USERNAME") or "test"
|
|
||||||
test_password = os.getenv("SSHECRET_TEST_PASSWORD") or "test"
|
|
||||||
if test_user and test_password:
|
|
||||||
return (test_user, test_password)
|
|
||||||
|
|
||||||
raise RuntimeError(
|
|
||||||
"Error: No testing username and password registered in environment."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def is_testing_mode() -> bool:
|
|
||||||
"""Check if we're running in test mode.
|
|
||||||
|
|
||||||
We will determine this by looking for the environment variable SSHECRET_TEST_MODE=1
|
|
||||||
"""
|
|
||||||
if os.environ.get("PYTEST_VERSION") is not None:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def create_test_user(session: Session, username: str, password: str) -> User:
|
|
||||||
"""Create test user.
|
|
||||||
|
|
||||||
We create a user with whatever username and password is supplied.
|
|
||||||
"""
|
|
||||||
salt = bcrypt.gensalt()
|
|
||||||
hashed_password = bcrypt.hashpw(password.encode(), salt)
|
|
||||||
user = User(username=username, hashed_password=hashed_password.decode())
|
|
||||||
session.add(user)
|
|
||||||
session.commit()
|
|
||||||
return user
|
|
||||||
|
|
||||||
@ -2,20 +2,14 @@
|
|||||||
|
|
||||||
from collections.abc import AsyncGenerator, Callable, Generator, Awaitable
|
from collections.abc import AsyncGenerator, Callable, Generator, Awaitable
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from sqlmodel import Session
|
|
||||||
from sshecret_admin.admin_backend import AdminBackend
|
from sshecret_admin.admin_backend import AdminBackend
|
||||||
from sshecret_admin.auth_models import User
|
from sshecret_admin.auth_models import User
|
||||||
from sshecret.backend import SshecretBackend
|
|
||||||
from . import keepass
|
|
||||||
|
|
||||||
|
|
||||||
DBSessionDep = Callable[[], Generator[Session, None, None]]
|
DBSessionDep = Callable[[], Generator[Session, None, None]]
|
||||||
|
|
||||||
BackendDep = Callable[[], AsyncGenerator[SshecretBackend, None]]
|
|
||||||
|
|
||||||
PasswdCtxDep = Callable[[DBSessionDep], AsyncGenerator[keepass.PasswordContext, None]]
|
|
||||||
|
|
||||||
AdminDep = Callable[[Session], AsyncGenerator[AdminBackend, None]]
|
AdminDep = Callable[[Session], AsyncGenerator[AdminBackend, None]]
|
||||||
|
|
||||||
UserTokenDep = Callable[[Request, Session], Awaitable[User]]
|
UserTokenDep = Callable[[Request, Session], Awaitable[User]]
|
||||||
|
|||||||
@ -1,113 +0,0 @@
|
|||||||
"""Models for the API."""
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
from typing import Annotated, Literal, Self, Union
|
|
||||||
from pydantic import (
|
|
||||||
AfterValidator,
|
|
||||||
BaseModel,
|
|
||||||
ConfigDict,
|
|
||||||
Field,
|
|
||||||
IPvAnyAddress,
|
|
||||||
IPvAnyNetwork,
|
|
||||||
model_validator,
|
|
||||||
)
|
|
||||||
from sshecret.crypto import validate_public_key
|
|
||||||
|
|
||||||
|
|
||||||
def public_key_validator(value: str) -> str:
|
|
||||||
"""Public key validator."""
|
|
||||||
if validate_public_key(value):
|
|
||||||
return value
|
|
||||||
raise ValueError("Error: Public key must be a valid RSA public key.")
|
|
||||||
|
|
||||||
|
|
||||||
class SecretListView(BaseModel):
|
|
||||||
"""Model containing a list of all available secrets."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
clients: list[str] = Field(default_factory=list) # Clients that have access to it.
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateKeyModel(BaseModel):
|
|
||||||
"""Model for updating client public key."""
|
|
||||||
|
|
||||||
public_key: Annotated[str, AfterValidator(public_key_validator)]
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateKeyResponse(BaseModel):
|
|
||||||
"""Response model after updating the public key."""
|
|
||||||
|
|
||||||
public_key: str
|
|
||||||
updated_secrets: list[str] = Field(default_factory=list)
|
|
||||||
detail: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class UpdatePoliciesRequest(BaseModel):
|
|
||||||
"""Update policy request."""
|
|
||||||
|
|
||||||
sources: list[IPvAnyAddress | IPvAnyNetwork]
|
|
||||||
|
|
||||||
|
|
||||||
class ClientCreate(BaseModel):
|
|
||||||
"""Model to create a client."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
public_key: Annotated[str, AfterValidator(public_key_validator)]
|
|
||||||
sources: list[IPvAnyAddress | IPvAnyNetwork] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class AutoGenerateOpts(BaseModel):
|
|
||||||
"""Option to auto-generate a password."""
|
|
||||||
|
|
||||||
auto_generate: Literal[True]
|
|
||||||
length: int = 32
|
|
||||||
|
|
||||||
|
|
||||||
class SecretUpdate(BaseModel):
|
|
||||||
"""Model to update a secret."""
|
|
||||||
|
|
||||||
value: str | AutoGenerateOpts = Field(
|
|
||||||
description="Secret as string value or auto-generated with optional length",
|
|
||||||
examples=["MySecretString", {"auto_generate": True, "length": 32}]
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_secret(self) -> str:
|
|
||||||
"""Get secret.
|
|
||||||
|
|
||||||
This returns the specified one, or generates one according to auto-generation.
|
|
||||||
"""
|
|
||||||
if isinstance(self.value, str):
|
|
||||||
return self.value
|
|
||||||
secret = secrets.token_urlsafe(self.value.length)
|
|
||||||
return secret
|
|
||||||
|
|
||||||
|
|
||||||
class SecretCreate(SecretUpdate):
|
|
||||||
"""Model to create a secret."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
clients: list[str] | None = Field(default=None, description="Assign the secret to a list of clients.")
|
|
||||||
|
|
||||||
model_config: ConfigDict = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"name": "MySecret",
|
|
||||||
"clients": ["client-1", "client-2"],
|
|
||||||
"value": { "auto_generate": True, "length": 32 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "MySecret",
|
|
||||||
"value": "mysecretstring",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
from .audit import create_audit_view
|
|
||||||
from .clients import create_client_view
|
|
||||||
from .secrets import create_secrets_view
|
|
||||||
|
|
||||||
__all__ = ["create_audit_view", "create_client_view", "create_secrets_view"]
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user