Compare commits
4 Commits
f1584d1841
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bfef232b8f | |||
| b31480bf25 | |||
| 9383f8cb03 | |||
| 67a04d67d9 |
@@ -9,7 +9,7 @@ api_key_bp = Blueprint('apikey', __name__, url_prefix='/api/apikey')
|
||||
|
||||
# An API key must never be able to request a role broader than what the
|
||||
# product defines, regardless of what the request body asks for.
|
||||
ALLOWED_API_KEY_ROLES = {'admin', 'creator', 'user'}
|
||||
ALLOWED_API_KEY_ROLES = {'admin', 'creator', 'user', 'agent'}
|
||||
|
||||
# Validity window applied on create and on every renewal.
|
||||
KEY_TTL = timedelta(days=15)
|
||||
|
||||
@@ -346,7 +346,7 @@ def convert_backup_endpoint():
|
||||
|
||||
backup_lock = threading.Lock()
|
||||
@backup_bp.route('/', methods=['GET'])
|
||||
@require_auth(roles=['admin'])
|
||||
@require_auth(roles=['admin', 'agent'])
|
||||
def get_backup():
|
||||
"""
|
||||
Create a backup of the application's data.
|
||||
@@ -558,7 +558,7 @@ def traverse(path_id, paths):
|
||||
|
||||
|
||||
@backup_bp.route('/load', methods=['POST'])
|
||||
@require_auth(roles=['admin'])
|
||||
@require_auth(roles=['admin', 'agent'])
|
||||
def load_backup():
|
||||
"""
|
||||
Restore data from a backup file.
|
||||
|
||||
@@ -194,7 +194,7 @@ def get_markdown(markdown_id):
|
||||
return jsonify(markdown.to_dict()), 200
|
||||
|
||||
@markdown_bp.route('/', methods=['POST'])
|
||||
@require_auth(roles=['admin', 'creator'])
|
||||
@require_auth(roles=['admin', 'creator', 'agent'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def create_markdown():
|
||||
"""
|
||||
@@ -250,7 +250,7 @@ def create_markdown():
|
||||
return jsonify({"error": f"create failed - {errno}"}), 500
|
||||
|
||||
@markdown_bp.route('/<int:markdown_id>', methods=['PUT', 'PATCH'])
|
||||
@require_auth(roles=['admin', 'creator'])
|
||||
@require_auth(roles=['admin', 'creator', 'agent'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def update_markdown(markdown_id):
|
||||
"""
|
||||
@@ -315,7 +315,7 @@ def update_markdown(markdown_id):
|
||||
return jsonify(markdown.to_dict()), 200
|
||||
|
||||
@markdown_bp.route('/<int:markdown_id>', methods=['DELETE'])
|
||||
@require_auth(roles=['admin'])
|
||||
@require_auth(roles=['admin', 'agent'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def delete_markdown(markdown_id):
|
||||
"""
|
||||
@@ -391,7 +391,7 @@ def delete_markdown(markdown_id):
|
||||
|
||||
|
||||
@markdown_bp.route('/move_forward/<int:markdown_id>', methods=['PATCH'])
|
||||
@require_auth(roles=['admin'])
|
||||
@require_auth(roles=['admin', 'agent'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def move_forward(markdown_id):
|
||||
"""
|
||||
@@ -428,7 +428,7 @@ def move_forward(markdown_id):
|
||||
|
||||
|
||||
@markdown_bp.route('/move_backward/<int:markdown_id>', methods=['PATCH'])
|
||||
@require_auth(roles=['admin'])
|
||||
@require_auth(roles=['admin', 'agent'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def move_backward(markdown_id):
|
||||
"""
|
||||
|
||||
@@ -51,7 +51,7 @@ def get_patches(markdown_id):
|
||||
|
||||
|
||||
@patch_bp.route('/', methods=['POST'])
|
||||
@require_auth(roles=['admin', 'creator'])
|
||||
@require_auth(roles=['admin', 'creator', 'agent'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def create_patch():
|
||||
"""Create a patch card. Body: markdown_id, content, title?, order?"""
|
||||
@@ -89,7 +89,7 @@ def create_patch():
|
||||
|
||||
|
||||
@patch_bp.route('/<int:patch_id>', methods=['PUT', 'PATCH'])
|
||||
@require_auth(roles=['admin', 'creator'])
|
||||
@require_auth(roles=['admin', 'creator', 'agent'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def update_patch(patch_id):
|
||||
"""Update a patch card (title/content/order)."""
|
||||
@@ -118,7 +118,7 @@ def update_patch(patch_id):
|
||||
|
||||
|
||||
@patch_bp.route('/<int:patch_id>', methods=['DELETE'])
|
||||
@require_auth(roles=['admin'])
|
||||
@require_auth(roles=['admin', 'agent'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def delete_patch(patch_id):
|
||||
"""Delete a patch card."""
|
||||
|
||||
12
api/path.py
12
api/path.py
@@ -82,7 +82,7 @@ def get_path_by_parent(parent_id):
|
||||
|
||||
@path_bp.route('/', methods=['POST'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@require_auth(roles=['admin', 'creator'])
|
||||
@require_auth(roles=['admin', 'creator', 'agent'])
|
||||
def create_path():
|
||||
"""
|
||||
Create a new path.
|
||||
@@ -119,7 +119,7 @@ def create_path():
|
||||
|
||||
@path_bp.route('/<int:path_id>', methods=['PUT'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@require_auth(roles=['admin'])
|
||||
@require_auth(roles=['admin', 'agent'])
|
||||
def update_path(path_id):
|
||||
"""
|
||||
Update a path.
|
||||
@@ -158,7 +158,7 @@ def update_path(path_id):
|
||||
|
||||
@path_bp.route('/<int:path_id>', methods=['PATCH'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@require_auth(roles=['admin'])
|
||||
@require_auth(roles=['admin', 'agent'])
|
||||
def patch_path(path_id):
|
||||
"""
|
||||
Partially update a path.
|
||||
@@ -205,7 +205,7 @@ def patch_path(path_id):
|
||||
|
||||
@path_bp.route('/<int:path_id>', methods=['DELETE'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
@require_auth(roles=['admin'])
|
||||
@require_auth(roles=['admin', 'agent'])
|
||||
def delete_path(path_id):
|
||||
"""
|
||||
Delete a path.
|
||||
@@ -240,7 +240,7 @@ def delete_path(path_id):
|
||||
|
||||
|
||||
@path_bp.route('/move_forward/<int:path_id>', methods=['PATCH'])
|
||||
@require_auth(roles=['admin'])
|
||||
@require_auth(roles=['admin', 'agent'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def move_forward(path_id):
|
||||
"""
|
||||
@@ -277,7 +277,7 @@ def move_forward(path_id):
|
||||
|
||||
|
||||
@path_bp.route('/move_backward/<int:path_id>', methods=['PATCH'])
|
||||
@require_auth(roles=['admin'])
|
||||
@require_auth(roles=['admin', 'agent'])
|
||||
@limiter.limit(api.get_rate_limit)
|
||||
def move_backward(path_id):
|
||||
"""
|
||||
|
||||
134
apikey_cli.py
Normal file
134
apikey_cli.py
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Admin CLI for API key management — no HTTP, no admin login.
|
||||
|
||||
Operates directly on the database (same env as the backend), so it must
|
||||
run where the DB is reachable, e.g. inside the backend container:
|
||||
|
||||
docker compose exec backend python apikey_cli.py create \
|
||||
--alias ci-bot --name "CI bot" --roles creator
|
||||
|
||||
docker compose exec backend python apikey_cli.py list
|
||||
docker compose exec backend python apikey_cli.py revoke --alias ci-bot
|
||||
|
||||
`create` with an existing --alias renews that key (same key string,
|
||||
validity reset, reactivated, name/roles updated) — matching the HTTP
|
||||
POST /api/apikey behaviour.
|
||||
"""
|
||||
import argparse
|
||||
import secrets
|
||||
import string
|
||||
import sys
|
||||
from datetime import datetime, timedelta, UTC
|
||||
|
||||
from db import get_db
|
||||
from db.models.APIKey import APIKey
|
||||
|
||||
# Keep in sync with api.apikey.ALLOWED_API_KEY_ROLES
|
||||
ALLOWED_ROLES = {"admin", "creator", "user", "agent"}
|
||||
KEY_TTL_DEFAULT_DAYS = 15
|
||||
|
||||
|
||||
def _gen_key(length=32):
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def _validate_roles(roles):
|
||||
bad = [r for r in roles if r not in ALLOWED_ROLES]
|
||||
if bad:
|
||||
sys.exit(f"error: invalid role(s) {bad}; allowed: {sorted(ALLOWED_ROLES)}")
|
||||
|
||||
|
||||
def cmd_create(args):
|
||||
alias = args.alias.strip()
|
||||
if not alias:
|
||||
sys.exit("error: --alias is required")
|
||||
roles = args.roles or []
|
||||
_validate_roles(roles)
|
||||
expire = datetime.now(UTC) + timedelta(days=args.ttl_days)
|
||||
|
||||
with get_db() as session:
|
||||
existing = session.query(APIKey).filter_by(alias=alias).first()
|
||||
if existing is not None:
|
||||
existing.name = args.name
|
||||
existing.roles = roles
|
||||
existing.is_active = True
|
||||
existing.expire = expire
|
||||
session.commit()
|
||||
row, renewed = existing.to_dict(), True
|
||||
else:
|
||||
ak = APIKey(
|
||||
key=_gen_key(), alias=alias, name=args.name,
|
||||
roles=roles, expire=expire,
|
||||
)
|
||||
session.add(ak)
|
||||
session.commit()
|
||||
row, renewed = ak.to_dict(), False
|
||||
|
||||
print("renewed" if renewed else "created")
|
||||
print(f" alias : {row['alias']}")
|
||||
print(f" name : {row['name']}")
|
||||
print(f" roles : {row['roles']}")
|
||||
print(f" expire: {row['expire']}")
|
||||
print(f" key : {row['key']}")
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
with get_db() as session:
|
||||
keys = session.query(APIKey).order_by(APIKey.created_at).all()
|
||||
rows = [k.to_dict() for k in keys]
|
||||
if not rows:
|
||||
print("(no API keys)")
|
||||
return
|
||||
for r in rows:
|
||||
key = r["key"] if args.show_keys else (r["key"][:6] + "…")
|
||||
state = "active" if r["is_active"] else "revoked"
|
||||
print(
|
||||
f"{r['alias']!r:<22} {state:<8} roles={r['roles']} "
|
||||
f"expire={r['expire']} last_used={r['last_used_at']} "
|
||||
f"name={r['name']!r} key={key}"
|
||||
)
|
||||
|
||||
|
||||
def cmd_revoke(args):
|
||||
with get_db() as session:
|
||||
q = session.query(APIKey)
|
||||
ak = (q.filter_by(alias=args.alias).first() if args.alias
|
||||
else q.filter_by(key=args.key).first())
|
||||
if ak is None:
|
||||
sys.exit("error: API key not found")
|
||||
ak.is_active = False
|
||||
session.commit()
|
||||
print(f"revoked: alias={ak.alias} name={ak.name!r}")
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(prog="apikey_cli", description=__doc__)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
c = sub.add_parser("create", help="create or renew (by alias) an API key")
|
||||
c.add_argument("--alias", required=True, help="unique alias; reuse to renew")
|
||||
c.add_argument("--name", required=True, help="human-readable name")
|
||||
c.add_argument("--roles", nargs="*", default=[],
|
||||
help=f"subset of {sorted(ALLOWED_ROLES)}")
|
||||
c.add_argument("--ttl-days", type=int, default=KEY_TTL_DEFAULT_DAYS,
|
||||
dest="ttl_days", help="validity window in days")
|
||||
c.set_defaults(func=cmd_create)
|
||||
|
||||
l = sub.add_parser("list", help="list all API keys")
|
||||
l.add_argument("--show-keys", action="store_true",
|
||||
help="print full key strings (default: masked)")
|
||||
l.set_defaults(func=cmd_list)
|
||||
|
||||
r = sub.add_parser("revoke", help="deactivate an API key")
|
||||
g = r.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--alias", help="revoke by alias")
|
||||
g.add_argument("--key", help="revoke by key string")
|
||||
r.set_defaults(func=cmd_revoke)
|
||||
|
||||
args = p.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user