Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: use decorators for admin api authentication #2860

Merged
merged 43 commits into from
May 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ea1c4f4
Add authentication decorators for admin API
esune Mar 28, 2024
66993c5
Updated multitenancy routes to use auth decorators
esune Mar 28, 2024
ec76806
Updated anoncreds routes to use auth decorators
esune Mar 28, 2024
cc4e23e
Updated server routes to use auth decorators
esune Mar 28, 2024
30cc9de
Updated holder routes to use auth decorators
esune Mar 28, 2024
c1c1480
Updated ledger routes to use auth decorators
esune Mar 28, 2024
9ca11aa
Updated messaging routes to use auth decorators
esune Mar 28, 2024
f70aa56
Updated resolver routes to use auth decorators
esune Mar 28, 2024
43ba336
Updated revocation routes to use auth decorators
esune Mar 28, 2024
d4988e5
Updated revocation_anoncreds routes to use auth decorators
esune Mar 28, 2024
695ad28
Updated vc routes to use auth decorators
esune Mar 28, 2024
3b971a9
Updated wallet routes to use auth decorators
esune Mar 28, 2024
ba80c24
Updated protocols routes to use auth decorators
esune Mar 28, 2024
2666f9c
Remove unused auth middleware code
esune Mar 28, 2024
e5be3ea
Remove authentication from readiness/liveliness handlers
esune Mar 28, 2024
17400ee
Fix typo
esune Apr 1, 2024
f4882a2
Fixed anoncreds route tests
esune Apr 1, 2024
9d5fffc
Fixed holder route tests
esune Apr 1, 2024
db4cd19
Fixed ledger route tests
esune Apr 1, 2024
c3a365b
Fixed multitenant route tests
esune Apr 1, 2024
7f1ab08
Fixed resolver route tests
esune Apr 1, 2024
700342b
Fixed revocation route tests
esune Apr 1, 2024
fddc6b9
Fixed revocation_anoncreds route tests
esune Apr 1, 2024
134aaa9
Fixed settings, wallet route tests
esune Apr 1, 2024
58ff03e
Fixed messaging route tests
esune Apr 1, 2024
e94c26d
Fixed protocols route tests
esune Apr 2, 2024
3018dab
Renamed functions for clarity
esune Apr 3, 2024
b03863f
Remove unused code, remove outdated test
esune Apr 8, 2024
5b53af6
Merge branch 'main' of https://github.com/hyperledger/aries-cloudagen…
esune Apr 26, 2024
4488d39
Extract server admin routes to own file
esune Apr 26, 2024
a528e73
Fix failing admin server tests
esune Apr 26, 2024
8db305d
Sync black version, reformat files
esune Apr 26, 2024
2ddfe6b
Merge branch 'main' of https://github.com/hyperledger/aries-cloudagen…
esune Apr 26, 2024
3a256ad
Merge branch 'main' of https://github.com/hyperledger/aries-cloudagen…
esune May 7, 2024
ece22ce
Re-lock dependencies after merging main
esune May 7, 2024
db370e0
Add auth decorator unit tests
esune May 8, 2024
45e43d5
Merge branch 'main' of https://github.com/hyperledger/aries-cloudagen…
esune May 8, 2024
a3d4f4e
Fix formatting conflicts
esune May 9, 2024
955f777
Fix formatting conflicts
esune May 9, 2024
b3a9e68
Fix formatting in argparse.py
esune May 9, 2024
37af0e2
Revert "Re-lock dependencies after merging main"
esune May 9, 2024
3599117
Limit lockfile update to black
esune May 9, 2024
6ccb6f9
Additional tweaks to lockfile
esune May 9, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ repos:
additional_dependencies: ['@commitlint/config-conventional']
- repo: https://github.com/psf/black
# Ensure this is synced with pyproject.toml
rev: 24.1.1
rev: 24.4.0
hooks:
- id: black
stages: [commit]
Expand Down
80 changes: 80 additions & 0 deletions aries_cloudagent/admin/decorators/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Authentication decorators for the admin API."""

import functools

from aiohttp import web

from ...utils import general as general_utils
from ..request_context import AdminRequestContext


def admin_authentication(handler):
"""Decorator to require authentication via admin API key.

The decorator will check for a valid x-api-key header and
reject the request if it is missing or invalid.
If the agent is running in insecure mode, the request will be allowed without a key.
"""

@functools.wraps(handler)
async def admin_auth(request):
context: AdminRequestContext = request["context"]
profile = context.profile
header_admin_api_key = request.headers.get("x-api-key")
valid_key = general_utils.const_compare(
profile.settings.get("admin.admin_api_key"), header_admin_api_key
)
insecure_mode = bool(profile.settings.get("admin.admin_insecure_mode"))
esune marked this conversation as resolved.
Show resolved Hide resolved

# We have to allow OPTIONS method access to paths without a key since
# browsers performing CORS requests will never include the original
# x-api-key header from the method that triggered the preflight
# OPTIONS check.
if insecure_mode or valid_key or (request.method == "OPTIONS"):
return await handler(request)
else:
raise web.HTTPUnauthorized(
reason="API Key invalid or missing",
text="API Key invalid or missing",
)

return admin_auth


def tenant_authentication(handler):
"""Decorator to enable non-admin authentication.

The decorator will:
- check for a valid bearer token in the Autorization header if running
in multi-tenant mode
- check for a valid x-api-key header if running in single-tenant mode
"""

@functools.wraps(handler)
async def tenant_auth(request):
context: AdminRequestContext = request["context"]
profile = context.profile
authorization_header = request.headers.get("Authorization")
header_admin_api_key = request.headers.get("x-api-key")
valid_key = general_utils.const_compare(
profile.settings.get("admin.admin_api_key"), header_admin_api_key
)
insecure_mode = bool(profile.settings.get("admin.admin_insecure_mode"))
esune marked this conversation as resolved.
Show resolved Hide resolved
multitenant_enabled = profile.settings.get("multitenant.enabled")

# CORS fix: allow OPTIONS method access to paths without a token
if (
(multitenant_enabled and authorization_header)
or (not multitenant_enabled and valid_key)
or insecure_mode
or request.method == "OPTIONS"
):
return await handler(request)
else:
auth_mode = "Authorization token" if multitenant_enabled else "API key"
raise web.HTTPUnauthorized(
reason=f"{auth_mode} missing or invalid",
text=f"{auth_mode} missing or invalid",
)

return tenant_auth
232 changes: 232 additions & 0 deletions aries_cloudagent/admin/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
"""Admin server routes."""

import asyncio
import re

from aiohttp import web
from aiohttp_apispec import (
docs,
response_schema,
)
from marshmallow import fields

from ..core.plugin_registry import PluginRegistry
from ..messaging.models.openapi import OpenAPISchema
from ..utils.stats import Collector
from ..version import __version__
from .decorators.auth import admin_authentication


class AdminModulesSchema(OpenAPISchema):
"""Schema for the modules endpoint."""

result = fields.List(
fields.Str(metadata={"description": "admin module"}),
metadata={"description": "List of admin modules"},
)


class AdminConfigSchema(OpenAPISchema):
"""Schema for the config endpoint."""

config = fields.Dict(
required=True, metadata={"description": "Configuration settings"}
)


class AdminStatusSchema(OpenAPISchema):
"""Schema for the status endpoint."""

version = fields.Str(metadata={"description": "Version code"})
label = fields.Str(allow_none=True, metadata={"description": "Default label"})
timing = fields.Dict(required=False, metadata={"description": "Timing results"})
conductor = fields.Dict(
required=False, metadata={"description": "Conductor statistics"}
)


class AdminResetSchema(OpenAPISchema):
"""Schema for the reset endpoint."""


class AdminStatusLivelinessSchema(OpenAPISchema):
"""Schema for the liveliness endpoint."""

alive = fields.Boolean(
metadata={"description": "Liveliness status", "example": True}
)


class AdminStatusReadinessSchema(OpenAPISchema):
"""Schema for the readiness endpoint."""

ready = fields.Boolean(
metadata={"description": "Readiness status", "example": True}
)


class AdminShutdownSchema(OpenAPISchema):
"""Response schema for admin Module."""


@docs(tags=["server"], summary="Fetch the list of loaded plugins")
@response_schema(AdminModulesSchema(), 200, description="")
@admin_authentication
async def plugins_handler(request: web.BaseRequest):
"""Request handler for the loaded plugins list.

Args:
request: aiohttp request object

Returns:
The module list response

"""
registry = request.app["context"].inject_or(PluginRegistry)
plugins = registry and sorted(registry.plugin_names) or []
return web.json_response({"result": plugins})


@docs(tags=["server"], summary="Fetch the server configuration")
@response_schema(AdminConfigSchema(), 200, description="")
@admin_authentication
async def config_handler(request: web.BaseRequest):
"""Request handler for the server configuration.

Args:
request: aiohttp request object

Returns:
The web response

"""
config = {
k: (
request.app["context"].settings[k]
if (isinstance(request.app["context"].settings[k], (str, int)))
else request.app["context"].settings[k].copy()
)
for k in request.app["context"].settings
if k
not in [
"admin.admin_api_key",
"multitenant.jwt_secret",
"wallet.key",
"wallet.rekey",
"wallet.seed",
"wallet.storage_creds",
]
}
for index in range(len(config.get("admin.webhook_urls", []))):
config["admin.webhook_urls"][index] = re.sub(
r"#.*",
"",
config["admin.webhook_urls"][index],
)

return web.json_response({"config": config})


@docs(tags=["server"], summary="Fetch the server status")
@response_schema(AdminStatusSchema(), 200, description="")
@admin_authentication
async def status_handler(request: web.BaseRequest):
"""Request handler for the server status information.

Args:
request: aiohttp request object

Returns:
The web response

"""
status = {"version": __version__}
status["label"] = request.app["context"].settings.get("default_label")
collector = request.app["context"].inject_or(Collector)
if collector:
status["timing"] = collector.results
if request.app["conductor_stats"]:
status["conductor"] = await request.app["conductor_stats"]()
return web.json_response(status)


@docs(tags=["server"], summary="Reset statistics")
@response_schema(AdminResetSchema(), 200, description="")
@admin_authentication
async def status_reset_handler(request: web.BaseRequest):
"""Request handler for resetting the timing statistics.

Args:
request: aiohttp request object

Returns:
The web response

"""
collector = request.app["context"].inject_or(Collector)
if collector:
collector.reset()
return web.json_response({})


async def redirect_handler(request: web.BaseRequest):
"""Perform redirect to documentation."""
raise web.HTTPFound("/api/doc")


@docs(tags=["server"], summary="Liveliness check")
@response_schema(AdminStatusLivelinessSchema(), 200, description="")
async def liveliness_handler(request: web.BaseRequest):
"""Request handler for liveliness check.

Args:
request: aiohttp request object

Returns:
The web response, always indicating True

"""
app_live = request.app._state["alive"]
if app_live:
return web.json_response({"alive": app_live})
else:
raise web.HTTPServiceUnavailable(reason="Service not available")


@docs(tags=["server"], summary="Readiness check")
@response_schema(AdminStatusReadinessSchema(), 200, description="")
async def readiness_handler(request: web.BaseRequest):
"""Request handler for liveliness check.

Args:
request: aiohttp request object

Returns:
The web response, indicating readiness for further calls

"""
app_ready = request.app._state["ready"] and request.app._state["alive"]
if app_ready:
return web.json_response({"ready": app_ready})
else:
raise web.HTTPServiceUnavailable(reason="Service not ready")


@docs(tags=["server"], summary="Shut down server")
@response_schema(AdminShutdownSchema(), description="")
@admin_authentication
async def shutdown_handler(request: web.BaseRequest):
"""Request handler for server shutdown.

Args:
request: aiohttp request object

Returns:
The web response (empty production)

"""
request.app._state["ready"] = False
loop = asyncio.get_event_loop()
asyncio.ensure_future(request.app["conductor_stop"](), loop=loop)

return web.json_response({})
Loading