Skip to content

Commit 96b9049

Browse files
authored
Merge pull request #3777 from camptocamp/renovate/master-own-packages
Update dependency c2casgiutils to v0.14.0 (master)
2 parents 204bcf8 + 26caf43 commit 96b9049

10 files changed

Lines changed: 162 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 2.0.0
44

5+
- Add a read-only mode to the admin interface: the configured `authentication.github_access_type` grants read-write access, while users with only a `pull` access on the configured `authentication.github_repository` can see the status and jobs but cannot start, cancel or retry generations.
56
- Surface generation errors in the admin interface by writing error messages to the job output before exiting, so they are captured in `job.message` and displayed in the admin UI.
67
- Fix unclosed aiohttp connector and client session warnings by properly closing Azure blob storage clients and aiohttp sessions in `URLTileStore` and `AzureStorageBlobTileStore`.
78
- Close stale tile store instances in `Server.get_store()` and `MultiTileStore._get_store()` when the cached store is invalidated by a configuration file change.

poetry.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ version = "0.0.0"
2323
[tool.poetry.dependencies]
2424
# Minimal version should also be set in the jsonschema-gentypes.yaml file
2525
python = ">=3.12,<3.15"
26-
c2casgiutils = { version = "0.13.4", extras = ["all"] }
26+
c2casgiutils = { version = "0.14.0", extras = ["all"] }
2727
fastapi = {extras = ["standard"], version = "0.141.1"}
2828
python-dateutil = "2.9.0.post0"
2929
tilecloud = { version = "1.13.5", extras = ["azure", "aws", "redis", "wsgi"] }

tilecloud_chain/CONFIG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
- <a id="properties/logging"></a>**`logging`**: Refer to *[#/definitions/logging](#definitions/logging)*.
3535
- <a id="properties/authentication"></a>**`authentication`** *(object)*: The authentication configuration. Cannot contain additional properties.
3636
- <a id="properties/authentication/properties/github_repository"></a>**`github_repository`** *(string)*: The GitHub repository name, on witch one we will check the access rights.
37-
- <a id="properties/authentication/properties/github_access_type"></a>**`github_access_type`** *(string)*: The kind of rights the user should have on the repository. Must be one of: "push", "pull", or "admin". Default: `"pull"`.
37+
- <a id="properties/authentication/properties/github_access_type"></a>**`github_access_type`** *(string)*: The kind of rights the user should have on the repository to have read-write access to the admin interface. Must be one of: "push", "pull", or "admin". Default: `"pull"`.
3838
## Definitions
3939

4040
- <a id="definitions/headers"></a>**`headers`** *(object)*: The headers that we send to the WMS backend. Can contain additional properties.

tilecloud_chain/USAGE.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -938,6 +938,13 @@ Admin and test pages
938938
On the URL `<base URL>/admin/` you can see the status of the generation, a tool to generate the tiles, and a link
939939
to a test page.
940940
941+
The access to the admin page is controlled by the ``authentication`` configuration section:
942+
943+
- The users with a ``github_access_type`` access on the configured ``github_repository`` have the read-write
944+
access: they can also launch a generation, and cancel or retry a job.
945+
- The users with only a ``pull`` access on the repository have a read-only access: they can see the status and
946+
the jobs, but cannot launch a generation, nor cancel or retry a job.
947+
941948
Beware, the test page assumes we have configured only one grid.
942949

943950
The test page stores its current state in the URL query string, so you can share or reload the same view:

tilecloud_chain/configuration.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ class Authentication(TypedDict, total=False):
127127
r"""
128128
GitHub access.
129129
130-
The kind of rights the user should have on the repository
130+
The kind of rights the user should have on the repository to have read-write access to the admin interface.
131131
132132
default: pull
133133
"""
@@ -957,7 +957,7 @@ class Generation(TypedDict, total=False):
957957
r"""
958958
GitHub access.
959959
960-
The kind of rights the user should have on the repository
960+
The kind of rights the user should have on the repository to have read-write access to the admin interface.
961961
962962
default: pull
963963
"""

tilecloud_chain/schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1707,7 +1707,7 @@
17071707
},
17081708
"github_access_type": {
17091709
"title": "GitHub access",
1710-
"description": "The kind of rights the user should have on the repository",
1710+
"description": "The kind of rights the user should have on the repository to have read-write access to the admin interface.",
17111711
"type": "string",
17121712
"default": "pull",
17131713
"enum": ["push", "pull", "admin"]

tilecloud_chain/templates/admin_index.html

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,22 @@ <h4 class="alert-heading">Use of deprecated attributes</h4>
8989
{% endfor %}
9090
</div>
9191
{% endif %}
92+
{% if read_only %}
93+
<div class="alert alert-info" role="alert">
94+
<p class="mb-1">
95+
You have read-only access: you can see the status and the jobs, but you cannot start, cancel or
96+
retry a generation.
97+
</p>
98+
</div>
99+
{% endif %}
92100
<span></span>
93101
{% if has_access %}
94102
<!---->
95103
{% if job_status is none %}
96104
<h2>Status</h2>
97105
<p>{{ '<br />'.join(status) | safe }}</p>
98106
{% endif %}
107+
{% if not read_only %}
99108
<h2>Launch generation</h2>
100109
<div class="dropdown">
101110
<button
@@ -157,6 +166,7 @@ <h2>Launch generation</h2>
157166
{% endif %}
158167
</form>
159168
{% endif %}
169+
{% endif %}
160170
<!---->
161171
{% if jobs_status is none %}
162172
<span id="result"></span>
@@ -200,13 +210,13 @@ <h2 class="accordion-header">
200210
<pre>{{ job.message.strip() | safe | escape }}</pre>
201211
{% endif %}
202212
<!---->
203-
{% if job.status == 'started' %}
213+
{% if not read_only and job.status == 'started' %}
204214
<p>
205215
<button type="button" class="btn btn-outline-primary cancel" data-job-id="{{ job.id }}">
206216
Cancel the tiles generation
207217
</button>
208218
</p>
209-
{% elif job.status == 'error' and errors %}
219+
{% elif not read_only and job.status == 'error' and errors %}
210220
<p>
211221
<button type="button" class="btn btn-outline-primary retry" data-job-id="{{ job.id }}">
212222
Retry to generate the tiles in error

tilecloud_chain/tests/test_admin.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# Copyright (c) 2026 by Camptocamp
2+
from types import SimpleNamespace
23
from typing import IO, Any
3-
from unittest.mock import Mock
4+
from unittest.mock import AsyncMock, Mock
45

56
import pytest
7+
from c2casgiutils.config import GitHubAccessType
68

79
from tilecloud_chain import DatedConfig
810
from tilecloud_chain.views import admin
@@ -124,3 +126,85 @@ async def test_validate_config_file_skips_non_wms_layers(monkeypatch: pytest.Mon
124126

125127
assert structure_errors == []
126128
assert deprecation_warnings == []
129+
130+
131+
def _patch_access(
132+
monkeypatch: pytest.MonkeyPatch,
133+
*,
134+
username: str | None = None,
135+
admin_access: bool = False,
136+
check_results: list[bool] | None = None,
137+
) -> AsyncMock:
138+
monkeypatch.setattr(
139+
admin.c2c_config,
140+
"settings",
141+
SimpleNamespace(auth=SimpleNamespace(test=SimpleNamespace(username=username))),
142+
)
143+
monkeypatch.setattr(admin.auth, "check_admin_access", AsyncMock(return_value=admin_access))
144+
check_mock = AsyncMock(side_effect=check_results or [])
145+
monkeypatch.setattr(admin.auth, "check_access_config", check_mock)
146+
return check_mock
147+
148+
149+
@pytest.mark.asyncio
150+
async def test_access_level_test_user(monkeypatch: pytest.MonkeyPatch) -> None:
151+
check = _patch_access(monkeypatch, username="tester")
152+
config = DatedConfig(config={}, mtime=0.0, file=Mock())
153+
154+
assert await admin._get_access_level(config, Mock()) is admin.AccessLevel.READ_WRITE
155+
check.assert_not_awaited()
156+
157+
158+
@pytest.mark.asyncio
159+
async def test_access_level_admin(monkeypatch: pytest.MonkeyPatch) -> None:
160+
check = _patch_access(monkeypatch, admin_access=True)
161+
config = DatedConfig(config={}, mtime=0.0, file=Mock())
162+
163+
assert await admin._get_access_level(config, Mock()) is admin.AccessLevel.READ_WRITE
164+
check.assert_not_awaited()
165+
166+
167+
@pytest.mark.asyncio
168+
async def test_access_level_read_write(monkeypatch: pytest.MonkeyPatch) -> None:
169+
check = _patch_access(monkeypatch, check_results=[True])
170+
config = DatedConfig(
171+
config={"authentication": {"github_repository": "org/repo", "github_access_type": "push"}},
172+
mtime=0.0,
173+
file=Mock(),
174+
)
175+
176+
assert await admin._get_access_level(config, Mock()) is admin.AccessLevel.READ_WRITE
177+
assert check.await_count == 1
178+
auth_config = check.await_args_list[0].args[1]
179+
assert auth_config.github_repository == "org/repo"
180+
assert auth_config.github_access_type_read_write is GitHubAccessType.PUSH
181+
assert auth_config.github_access_type_read_only is None
182+
183+
184+
@pytest.mark.asyncio
185+
async def test_access_level_read_only_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
186+
check = _patch_access(monkeypatch, check_results=[False, True])
187+
config = DatedConfig(
188+
config={"authentication": {"github_repository": "org/repo", "github_access_type": "push"}},
189+
mtime=0.0,
190+
file=Mock(),
191+
)
192+
193+
assert await admin._get_access_level(config, Mock()) is admin.AccessLevel.READ_ONLY
194+
assert check.await_count == 2
195+
read_only_config = check.await_args_list[1].args[1]
196+
assert read_only_config.github_access_type_read_only is GitHubAccessType.PULL
197+
assert read_only_config.github_access_type_read_write is None
198+
199+
200+
@pytest.mark.asyncio
201+
async def test_access_level_no_access(monkeypatch: pytest.MonkeyPatch) -> None:
202+
check = _patch_access(monkeypatch, check_results=[False, False])
203+
config = DatedConfig(
204+
config={"authentication": {"github_repository": "org/repo", "github_access_type": "push"}},
205+
mtime=0.0,
206+
file=Mock(),
207+
)
208+
209+
assert await admin._get_access_level(config, Mock()) is admin.AccessLevel.NO_ACCESS
210+
assert check.await_count == 2

tilecloud_chain/views/admin.py

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import shlex
3737
import urllib.parse
3838
from collections.abc import Awaitable, Callable
39+
from enum import StrEnum
3940
from typing import IO, Annotated, Any, cast
4041

4142
import jsonschema_validator
@@ -95,35 +96,59 @@ async def _get_postgresql_store() -> tilecloud_chain.store.postgresql.Postgresql
9596
return _postgresql_store
9697

9798

98-
async def _get_access(
99+
class AccessLevel(StrEnum):
100+
"""Level of access of a user to the admin interface."""
101+
102+
NO_ACCESS = "no_access"
103+
READ_ONLY = "read_only"
104+
READ_WRITE = "read_write"
105+
106+
107+
async def _get_access_level(
99108
config: Annotated[tilecloud_chain.DatedConfig, Depends(server.get_host_config)],
100109
auth_info: Annotated[auth.AuthInfo, Depends(auth.get_auth)],
101-
) -> bool:
102-
"""Check if the user has access to admin functions."""
110+
) -> AccessLevel:
111+
"""Get the level of access of the user to the admin interface."""
103112

104113
if c2c_config.settings.auth.test.username:
105-
return True
114+
return AccessLevel.READ_WRITE
106115

107116
if await auth.check_admin_access(auth_info):
108-
return True
117+
return AccessLevel.READ_WRITE
109118

110119
auth_config = config.config.get("authentication", {})
111-
return await auth.check_access_config(
120+
github_repository = auth_config.get("github_repository", "")
121+
github_access_type = c2c_config.GitHubAccessType(auth_config.get("github_access_type", "pull"))
122+
123+
if await auth.check_access_config(
112124
auth_info,
113125
auth.AuthConfig(
114-
github_repository=auth_config.get("github_repository", ""),
115-
github_access_type=auth_config.get("github_access_type", ""),
126+
github_repository=github_repository,
127+
github_access_type_read_write=github_access_type,
116128
),
117-
)
129+
):
130+
return AccessLevel.READ_WRITE
118131

132+
# A "pull" access grants read-only access.
133+
if await auth.check_access_config(
134+
auth_info,
135+
auth.AuthConfig(
136+
github_repository=github_repository,
137+
github_access_type_read_only=c2c_config.GitHubAccessType.PULL,
138+
),
139+
):
140+
return AccessLevel.READ_ONLY
119141

120-
async def _check_access(
121-
has_access: Annotated[dict[str, Any], Depends(_get_access)],
142+
return AccessLevel.NO_ACCESS
143+
144+
145+
async def _check_read_write_access(
146+
access_level: Annotated[AccessLevel, Depends(_get_access_level)],
122147
) -> None:
123-
"""Check if the user has access to admin functions."""
148+
"""Check that the user has read-write access to admin functions."""
124149

125-
if not has_access:
126-
raise HTTPException(status_code=403, detail="Access forbidden")
150+
if access_level is not AccessLevel.READ_WRITE:
151+
raise HTTPException(status_code=403, detail="Write access forbidden")
127152

128153

129154
# Pydantic models for request/response
@@ -207,7 +232,7 @@ async def admin_index(
207232
config: Annotated[tilecloud_chain.DatedConfig, Depends(server.get_host_config)],
208233
gene: Annotated[TileGeneration, Depends(_get_tilegeneration)],
209234
auth_info: Annotated[auth.AuthInfo, Depends(auth.get_auth)],
210-
has_access: Annotated[bool, Depends(_get_access)],
235+
access_level: Annotated[AccessLevel, Depends(_get_access_level)],
211236
auth_type: Annotated[auth.AuthenticationType, Depends(auth.auth_type)],
212237
secret: Annotated[str | None, Query(..., description="Secret key for authentication")] = None,
213238
) -> HTMLResponse:
@@ -218,6 +243,9 @@ async def admin_index(
218243
jobs_status = None
219244
queue_store = main_config.config.get("queue_store", configuration.QUEUE_STORE_DEFAULT)
220245

246+
has_access = access_level is not AccessLevel.NO_ACCESS
247+
read_only = access_level is AccessLevel.READ_ONLY
248+
221249
structure_errors: list[str] = []
222250
deprecation_warnings: list[str] = []
223251
if config.file and has_access:
@@ -236,6 +264,7 @@ async def admin_index(
236264
"request": request,
237265
"nonce": nonce,
238266
"has_access": has_access,
267+
"read_only": read_only,
239268
"auth_info": auth_info,
240269
"auth_type": auth_type,
241270
"secret": secret,
@@ -259,7 +288,7 @@ async def admin_run(
259288
request: Request,
260289
command: Annotated[str, Form(...)],
261290
gene: Annotated[TileGeneration, Depends(_get_tilegeneration)],
262-
_: Annotated[None, Depends(_check_access)],
291+
_: Annotated[None, Depends(_check_read_write_access)],
263292
) -> CommandResponse:
264293
"""Run the command given by the user."""
265294
commands = shlex.split(command)
@@ -366,7 +395,7 @@ async def admin_create_job(
366395
tilecloud_chain.store.postgresql.PostgresqlTileStore,
367396
Depends(_get_postgresql_store),
368397
],
369-
_: Annotated[None, Depends(_check_access)],
398+
_: Annotated[None, Depends(_check_read_write_access)],
370399
) -> JobResponse:
371400
"""Create a job."""
372401
try:
@@ -391,7 +420,7 @@ async def admin_cancel_job(
391420
tilecloud_chain.store.postgresql.PostgresqlTileStore,
392421
Depends(_get_postgresql_store),
393422
],
394-
_: Annotated[None, Depends(_check_access)],
423+
_: Annotated[None, Depends(_check_read_write_access)],
395424
) -> JobResponse:
396425
"""Cancel a job."""
397426
try:
@@ -416,7 +445,7 @@ async def admin_retry_job(
416445
tilecloud_chain.store.postgresql.PostgresqlTileStore,
417446
Depends(_get_postgresql_store),
418447
],
419-
_: Annotated[None, Depends(_check_access)],
448+
_: Annotated[None, Depends(_check_read_write_access)],
420449
) -> JobResponse:
421450
"""Retry a job."""
422451
try:

0 commit comments

Comments
 (0)