🛂 — Allow partial or total anonymous access to web interface (fix #63)

This commit is contained in:
Luc Didry 2024-11-28 11:48:08 +01:00
parent 841f8638de
commit da221b856b
No known key found for this signature in database
GPG key ID: EA868E12D0257E3C
8 changed files with 103 additions and 7 deletions

View file

@ -13,6 +13,7 @@
BREAKING CHANGE: `mo` is no longer accepted for declaring a duration in month in the configuration BREAKING CHANGE: `mo` is no longer accepted for declaring a duration in month in the configuration
You need to use `M`, `month` or `months` You need to use `M`, `month` or `months`
- ✨ - Allow to choose a frequency smaller than a minute - ✨ - Allow to choose a frequency smaller than a minute
- ✨🛂 — Allow partial or total anonymous access to web interface (#63)
## 0.5.0 ## 0.5.0

View file

@ -1,5 +1,7 @@
--- ---
general: general:
# Except for frequency and recheck_delay settings, changes in general
# section of the configuration will need a restart of argos server.
db: db:
# The database URL, as defined in SQLAlchemy docs : # The database URL, as defined in SQLAlchemy docs :
# https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls # https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls
@ -28,6 +30,12 @@ general:
# If not present, the "Remember me" feature is not available # If not present, the "Remember me" feature is not available
# remember_me_duration: "1M" # remember_me_duration: "1M"
# Unauthenticated access
# If can grant an unauthenticated access to the dashboard or to all pages
# To do so, choose either "dashboard", or "all"
# If not present, all pages needs authentication
# unauthenticated_access: "all"
# Default delay for checks. # Default delay for checks.
# Can be superseeded in domain configuration. # Can be superseeded in domain configuration.
# For ex., to run checks every 5 minutes: # For ex., to run checks every 5 minutes:

View file

@ -27,6 +27,7 @@ from argos.schemas.utils import Method
Severity = Literal["warning", "error", "critical", "unknown"] Severity = Literal["warning", "error", "critical", "unknown"]
Environment = Literal["dev", "test", "production"] Environment = Literal["dev", "test", "production"]
Unauthenticated = Literal["dashboard", "all"]
SQLiteDsn = Annotated[ SQLiteDsn = Annotated[
Url, Url,
UrlConstraints( UrlConstraints(
@ -190,6 +191,7 @@ class General(BaseModel):
cookie_secret: str cookie_secret: str
session_duration: int = 10080 # 7 days session_duration: int = 10080 # 7 days
remember_me_duration: int | None = None remember_me_duration: int | None = None
unauthenticated_access: Unauthenticated | None = None
frequency: float frequency: float
recheck_delay: float | None = None recheck_delay: float | None = None
root_path: str = "" root_path: str = ""

View file

@ -100,7 +100,7 @@ def setup_database(appli):
models.Base.metadata.create_all(bind=engine) models.Base.metadata.create_all(bind=engine)
def create_manager(cookie_secret): def create_manager(cookie_secret: str) -> LoginManager:
if cookie_secret == "foo_bar_baz": if cookie_secret == "foo_bar_baz":
logger.warning( logger.warning(
"You should change the cookie_secret secret in your configuration file." "You should change the cookie_secret secret in your configuration file."

View file

@ -18,6 +18,9 @@ def get_config(request: Request):
async def get_manager(request: Request) -> LoginManager: async def get_manager(request: Request) -> LoginManager:
if request.app.state.config.general.unauthenticated_access is not None:
return await request.app.state.manager.optional(request)
return await request.app.state.manager(request) return await request.app.state.manager(request)

View file

@ -17,6 +17,7 @@ from sqlalchemy.orm import Session
from argos.checks.base import Status from argos.checks.base import Status
from argos.schemas import Config from argos.schemas import Config
from argos.server import queries from argos.server import queries
from argos.server.exceptions import NotAuthenticatedException
from argos.server.models import Result, Task, User from argos.server.models import Result, Task, User
from argos.server.routes.dependencies import get_config, get_db, get_manager from argos.server.routes.dependencies import get_config, get_db, get_manager
@ -33,6 +34,12 @@ async def login_view(
msg: str | None = None, msg: str | None = None,
config: Config = Depends(get_config), config: Config = Depends(get_config),
): ):
if config.general.unauthenticated_access == "all":
return RedirectResponse(
request.url_for("get_severity_counts_view"),
status_code=status.HTTP_303_SEE_OTHER,
)
token = request.cookies.get("access-token") token = request.cookies.get("access-token")
if token is not None and token != "": if token is not None and token != "":
manager = request.app.state.manager manager = request.app.state.manager
@ -66,6 +73,12 @@ async def post_login(
rememberme: Annotated[str | None, Form()] = None, rememberme: Annotated[str | None, Form()] = None,
config: Config = Depends(get_config), config: Config = Depends(get_config),
): ):
if config.general.unauthenticated_access == "all":
return RedirectResponse(
request.url_for("get_severity_counts_view"),
status_code=status.HTTP_303_SEE_OTHER,
)
username = data.username username = data.username
user = await queries.get_user(db, username) user = await queries.get_user(db, username)
invalid_credentials = templates.TemplateResponse( invalid_credentials = templates.TemplateResponse(
@ -103,7 +116,17 @@ async def post_login(
@route.get("/logout") @route.get("/logout")
async def logout_view(request: Request, user: User | None = Depends(get_manager)): async def logout_view(
request: Request,
config: Config = Depends(get_config),
user: User | None = Depends(get_manager),
):
if config.general.unauthenticated_access == "all":
return RedirectResponse(
request.url_for("get_severity_counts_view"),
status_code=status.HTTP_303_SEE_OTHER,
)
response = RedirectResponse( response = RedirectResponse(
request.url_for("login_view").include_query_params(msg="logout"), request.url_for("login_view").include_query_params(msg="logout"),
status_code=status.HTTP_303_SEE_OTHER, status_code=status.HTTP_303_SEE_OTHER,
@ -133,6 +156,7 @@ async def get_severity_counts_view(
"agents": agents, "agents": agents,
"auto_refresh_enabled": auto_refresh_enabled, "auto_refresh_enabled": auto_refresh_enabled,
"auto_refresh_seconds": auto_refresh_seconds, "auto_refresh_seconds": auto_refresh_seconds,
"user": user,
}, },
) )
@ -141,9 +165,14 @@ async def get_severity_counts_view(
async def get_domains_view( async def get_domains_view(
request: Request, request: Request,
user: User | None = Depends(get_manager), user: User | None = Depends(get_manager),
config: Config = Depends(get_config),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Show all tasks and their current state""" """Show all tasks and their current state"""
if config.general.unauthenticated_access == "dashboard":
if user is None:
raise NotAuthenticatedException
tasks = db.query(Task).all() tasks = db.query(Task).all()
domains_severities = defaultdict(list) domains_severities = defaultdict(list)
@ -184,6 +213,7 @@ async def get_domains_view(
"last_checks": domains_last_checks, "last_checks": domains_last_checks,
"total_task_count": len(tasks), "total_task_count": len(tasks),
"agents": agents, "agents": agents,
"user": user,
}, },
) )
@ -193,12 +223,23 @@ async def get_domain_tasks_view(
request: Request, request: Request,
domain: str, domain: str,
user: User | None = Depends(get_manager), user: User | None = Depends(get_manager),
config: Config = Depends(get_config),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Show all tasks attached to a domain""" """Show all tasks attached to a domain"""
if config.general.unauthenticated_access == "dashboard":
if user is None:
raise NotAuthenticatedException
tasks = db.query(Task).filter(Task.domain.contains(f"//{domain}")).all() tasks = db.query(Task).filter(Task.domain.contains(f"//{domain}")).all()
return templates.TemplateResponse( return templates.TemplateResponse(
"domain.html", {"request": request, "domain": domain, "tasks": tasks} "domain.html",
{
"request": request,
"domain": domain,
"tasks": tasks,
"user": user,
},
) )
@ -207,12 +248,23 @@ async def get_result_view(
request: Request, request: Request,
result_id: int, result_id: int,
user: User | None = Depends(get_manager), user: User | None = Depends(get_manager),
config: Config = Depends(get_config),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Show the details of a result""" """Show the details of a result"""
if config.general.unauthenticated_access == "dashboard":
if user is None:
raise NotAuthenticatedException
result = db.query(Result).get(result_id) result = db.query(Result).get(result_id)
return templates.TemplateResponse( return templates.TemplateResponse(
"result.html", {"request": request, "result": result, "error": Status.ERROR} "result.html",
{
"request": request,
"result": result,
"error": Status.ERROR,
"user": user,
},
) )
@ -225,6 +277,10 @@ async def get_task_results_view(
config: Config = Depends(get_config), config: Config = Depends(get_config),
): ):
"""Show history of a tasks results""" """Show history of a tasks results"""
if config.general.unauthenticated_access == "dashboard":
if user is None:
raise NotAuthenticatedException
results = ( results = (
db.query(Result) db.query(Result)
.filter(Result.task_id == task_id) .filter(Result.task_id == task_id)
@ -243,6 +299,7 @@ async def get_task_results_view(
"task": task, "task": task,
"description": description, "description": description,
"error": Status.ERROR, "error": Status.ERROR,
"user": user,
}, },
) )
@ -251,9 +308,14 @@ async def get_task_results_view(
async def get_agents_view( async def get_agents_view(
request: Request, request: Request,
user: User | None = Depends(get_manager), user: User | None = Depends(get_manager),
config: Config = Depends(get_config),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Show argos agents and the last time the server saw them""" """Show argos agents and the last time the server saw them"""
if config.general.unauthenticated_access == "dashboard":
if user is None:
raise NotAuthenticatedException
last_seen = ( last_seen = (
db.query(Result.agent_id, func.max(Result.submitted_at).label("submitted_at")) db.query(Result.agent_id, func.max(Result.submitted_at).label("submitted_at"))
.group_by(Result.agent_id) .group_by(Result.agent_id)
@ -261,7 +323,12 @@ async def get_agents_view(
) )
return templates.TemplateResponse( return templates.TemplateResponse(
"agents.html", {"request": request, "last_seen": last_seen} "agents.html",
{
"request": request,
"last_seen": last_seen,
"user": user,
},
) )

View file

@ -63,6 +63,8 @@
Agents Agents
</a> </a>
</li> </li>
{% set unauthenticated_access = request.app.state.config.general.unauthenticated_access %}
{% if (user is defined and user is not none) or unauthenticated_access == "all" %}
<li> <li>
<a href="#" <a href="#"
id="reschedule-all" id="reschedule-all"
@ -72,13 +74,24 @@
Reschedule non-ok checks Reschedule non-ok checks
</a> </a>
</li> </li>
{% endif %}
{% if user is defined and user is not none %}
<li> <li>
<a href="{{ url_for('logout_view') }}" <a href="{{ url_for('logout_view') }}"
class="outline {{ 'contrast' if request.url == url_for('get_agents_view') }}" class="outline }}"
role="button"> role="button">
Logout Logout
</a> </a>
</li> </li>
{% elif unauthenticated_access != "all" %}
<li>
<a href="{{ url_for('login_view') }}"
class="outline }}"
role="button">
Login
</a>
</li>
{% endif %}
</ul> </ul>
</details> </details>
</li> </li>

View file

@ -276,7 +276,9 @@ Options:
### Server user management ### Server user management
To access Argos web interface, you need to create at least one user. You can choose to protect Argos web interface with a user system, in which case youll need to create at least one user.
See [`unauthenticated_access` in the configuration file](configuration.md) to allow partial or total unauthenticated access to Argos.
You can manage users only through CLI. You can manage users only through CLI.