OR semantics in perm query arg, strict parsing, segment-aware wildcards

perm=a|b+c now means (a or b) and c; repeated perm args remain ANDed.
Out-of-spec values (empty alternatives, chars outside the scope charset,
stray %2B) are rejected with 400 instead of being silently misparsed;
extra spaces between groups are tolerated. Forward endpoint 400/500
details name /auth/api/forward as origin without echoing query args.
Wildcards are now filename-like: * stays within a :- or /-separated
segment, ** spans segments, partial segments allowed. Slash added to
allowed scope characters for path-based permissions.
This commit is contained in:
2026-08-11 00:44:58 +00:00
parent 00ef0ae2e7
commit 79074dd4f1
8 changed files with 378 additions and 26 deletions
+2 -1
View File
@@ -34,10 +34,11 @@ Connection hop-by-hop headers (Connection, Upgrade, Transfer-Encoding, etc.) mus
No response body. Only [Remote headers](../Headers.md) are set on the response and your proxy should forward them to the backend request. The headers, not a response body, are the whole point of this endpoint: they are how the authenticated identity reaches the protected service, which can trust them because it is only reachable through the proxy. No response body. Only [Remote headers](../Headers.md) are set on the response and your proxy should forward them to the backend request. The headers, not a response body, are the whole point of this endpoint: they are how the authenticated identity reaches the protected service, which can trust them because it is only reachable through the proxy.
### Failure (401 / 403) ### Failure (400 / 401 / 403)
| Status | Meaning | | Status | Meaning |
|---|---| |---|---|
| 400 | Malformed perm argument (see [perm](perm.md#syntax-errors)). The error detail names `/auth/api/forward` as the origin and never echoes query arguments. |
| 401 | Session missing or expired, or max_age not satisfied — the user needs to (re)authenticate. | | 401 | Session missing or expired, or max_age not satisfied — the user needs to (re)authenticate. |
| 403 | Requested permissions are missing — the forbidden flow allows signing in with another account. | | 403 | Requested permissions are missing — the forbidden flow allows signing in with another account. |
+31 -5
View File
@@ -15,7 +15,7 @@ Repeat the query parameter for each required scope:
?perm=myapp:read&perm=myapp:write ?perm=myapp:read&perm=myapp:write
``` ```
You can also pass multiple scopes in one parameter by separating them with whitespace: You can also pass multiple scopes in one parameter by separating them with whitespace (a literal space, i.e. `+` or `%20` in the query string):
```text ```text
?perm=myapp:read%20myapp:write ?perm=myapp:read%20myapp:write
@@ -25,19 +25,39 @@ Both forms produce the same result.
## Semantics ## Semantics
The perm argument uses **AND** semantics: **every** listed scope must be present in the effective permissions for the request to succeed. If any required scope is missing, the endpoint returns 403. The perm argument uses **AND** semantics between groups: **every** listed scope group must be satisfied by the effective permissions for the request to succeed. If any required group is not satisfied, the endpoint returns 403.
There is **no OR** support inside a single call. If you need to check "scope A or scope B", make separate calls or check the returned permission list in your own backend. Within a single group, use `|` to list alternatives with **OR** semantics — the group is satisfied when **any one** of the alternatives is present:
```text
?perm=myapp:read|myapp:write+myapp:login
```
This requires `myapp:login` **and** (`myapp:read` **or** `myapp:write`). No whitespace is allowed around the `|` operator.
## Syntax errors
Parsing is strict: anything out of spec is rejected with **400 Bad Request** rather than guessed at. This includes:
- Empty values or alternatives (`?perm=`, `?perm=a||b`, `?perm=|a`, `?perm=a|`)
- Whitespace around `|` (`?perm=a+|+b`)
- Characters not allowed in scopes other than the operators (space and `|`); scopes match `^[A-Za-z0-9:._~/-]+$` plus the `*` wildcard. In particular a literal `+` in the decoded value (from a `%2B` in the query string) is rejected — use `+` or `%20` to encode a space, never `%2B`.
Extra spaces between groups (leading, trailing, or repeated) are tolerated, since they can easily result from URL formatting and carry no ambiguity — they only ever add required permissions, never remove them. The `|` operator is parsed strictly: `?perm=foo&perm=|bar` is an error, never a way to make `foo` optional.
## Wildcards ## Wildcards
A required scope may contain the * wildcard, which matches any sequence of characters: Wildcards work like filenames, with `:` and `/` acting as path separators:
- `*` matches any sequence of characters **within a single segment** (it never crosses a `:` or `/`)
- `**` matches any sequence of characters, **across separators**
- Part of a segment can be wildcarded, with required text on either or both sides
```text ```text
?perm=myapp:* ?perm=myapp:*
``` ```
This matches myapp:read, myapp:write, and any other scope starting with myapp:. This matches myapp:read and myapp:write, but **not** myapp:read:all — use `myapp:**` for that. Partial wildcards like `myapp:re*` or `myapp:r*d` match myapp:read. The same applies to path-based scopes: `myapp:path:/api/*` matches myapp:path:/api/clients but not myapp:path:/api/v2/clients — use `myapp:path:/api/**` to span path segments.
## Effective permissions ## Effective permissions
@@ -68,3 +88,9 @@ Require any scope under myapp:
```text ```text
?perm=myapp:* ?perm=myapp:*
``` ```
Require myapp:login and either myapp:read or myapp:write:
```text
?perm=myapp:login&perm=myapp:read|myapp:write
```
+2 -1
View File
@@ -55,10 +55,11 @@ The endpoint always responds with JSON.
If the response includes a Set-Cookie header, the session has been renewed (renewed is true) and you should forward that cookie to the client so the browser updates its expiry. Not forwarding it means the session lifetime is not extended, so the user may need to re-authenticate sooner. Renewals are throttled, so frequent calls usually return renewed: false with no Set-Cookie. If the response includes a Set-Cookie header, the session has been renewed (renewed is true) and you should forward that cookie to the client so the browser updates its expiry. Not forwarding it means the session lifetime is not extended, so the user may need to re-authenticate sooner. Renewals are throttled, so frequent calls usually return renewed: false with no Set-Cookie.
### Failure (401 / 403) ### Failure (400 / 401 / 403)
| Status | Meaning | | Status | Meaning |
|---|---| |---|---|
| 400 | Malformed perm argument (see [perm](perm.md#syntax-errors)). |
| 401 | Session missing, expired, or max_age not satisfied. The response body includes auth metadata for the login/reauth iframe. | | 401 | Session missing, expired, or max_age not satisfied. The response body includes auth metadata for the login/reauth iframe. |
| 403 | Session is valid but one or more requested permissions are missing. | | 403 | Session is valid but one or more requested permissions are missing. |
+30 -8
View File
@@ -79,10 +79,22 @@ async def auth_exception_handler(_request: Request, exc: authz.AuthException):
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def general_exception_handler( async def general_exception_handler(
_request: Request, exc: Exception request: Request, exc: Exception
): # pragma: no cover ): # pragma: no cover
logging.exception("Unhandled exception in API app") logging.exception("Unhandled exception in API app")
return JSONResponse(status_code=500, content={"detail": "Internal server error"}) # Identify the origin endpoint for proxied clients (e.g. forward auth)
return JSONResponse(
status_code=500,
content={"detail": f"{request.url.path}: Internal server error"},
)
def _parse_perm(perm: list[str]) -> list[tuple[str, ...]]:
"""Parse perm query arguments into groups of OR alternatives (400 on syntax error)."""
try:
return permutil.parse_perm_args(perm)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/validate") @app.post("/validate")
@@ -94,10 +106,11 @@ async def validate_token(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
"""Validate session and return context. Refreshes session expiry.""" """Validate session and return context. Refreshes session expiry."""
perm_groups = _parse_perm(perm)
try: try:
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
" ".join(perm).split(), perm_groups,
host=request.headers.get("host"), host=request.headers.get("host"),
max_age=max_age, max_age=max_age,
) )
@@ -137,7 +150,8 @@ async def check_user(
Query Params: Query Params:
- user: UUID of the user to check. - user: UUID of the user to check.
- perm: repeated permission scope the user must possess (ALL required). - perm: repeated permission scope the user must possess (ALL required;
separate alternatives with '|' for OR semantics within a group).
Returns 200 with valid=True/False and the user's effective permissions, Returns 200 with valid=True/False and the user's effective permissions,
scoped to the requesting host (domain-restricted permissions are filtered). scoped to the requesting host (domain-restricted permissions are filtered).
@@ -168,9 +182,9 @@ async def check_user(
continue continue
effective_perms.append(p) effective_perms.append(p)
required = " ".join(perm).split() required_groups = _parse_perm(perm)
effective_scopes = {p.scope for p in effective_perms} effective_scopes = {p.scope for p in effective_perms}
valid = permutil.has_all_scopes(effective_scopes, required) valid = permutil.has_all_scopes_groups(effective_scopes, required_groups)
ctx = ApiSessionContext( ctx = ApiSessionContext(
user=ApiUserContext(uuid=u.uuid, display_name=u.display_name, theme=u.theme), user=ApiUserContext(uuid=u.uuid, display_name=u.display_name, theme=u.theme),
@@ -192,7 +206,8 @@ async def forward_authentication(
"""Forward auth validation for Caddy/Nginx. """Forward auth validation for Caddy/Nginx.
Query Params: Query Params:
- perm: repeated permission IDs the authenticated user must possess (ALL required). - perm: repeated permission scopes the authenticated user must possess (ALL
required; separate alternatives with '|' for OR semantics within a group).
- max_age: maximum age of authentication (e.g., "5m", "1h", "30s"). If the session - max_age: maximum age of authentication (e.g., "5m", "1h", "30s"). If the session
is older than this, user must re-authenticate. is older than this, user must re-authenticate.
@@ -212,10 +227,17 @@ async def forward_authentication(
) )
_set_log_extra(request, forwarded) _set_log_extra(request, forwarded)
try:
perm_groups = permutil.parse_perm_args(perm)
except ValueError:
# Identify the error origin for proxied clients; do not echo query args
raise HTTPException(
status_code=400, detail="/auth/api/forward: invalid perm argument"
)
try: try:
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
" ".join(perm).split(), perm_groups,
host=request.headers.get("host"), host=request.headers.get("host"),
max_age=max_age, max_age=max_age,
) )
+16 -5
View File
@@ -1,4 +1,5 @@
import logging import logging
from collections.abc import Callable
from fastapi import HTTPException from fastapi import HTTPException
@@ -54,13 +55,17 @@ async def auth_error_content(exc: AuthException) -> dict:
async def verify( async def verify(
auth: str | None, auth: str | None,
perm: list[str], perm: list[str] | list[tuple[str, ...]],
match=permutil.has_all, match: "Callable | None" = None,
host: str | None = None, host: str | None = None,
max_age: str | None = None, max_age: str | None = None,
): ):
"""Validate session token and optional list of required permissions. """Validate session token and optional list of required permissions.
Each perm entry is either a scope pattern or a tuple of alternative
scope patterns (OR semantics within a group). All entries must be
satisfied (AND semantics).
Returns the session context. Returns the session context.
Raises AuthException on failure with metadata for UI rendering. Raises AuthException on failure with metadata for UI rendering.
@@ -97,15 +102,21 @@ async def verify(
# Invalid max_age format - log but don't fail the request # Invalid max_age format - log but don't fail the request
logger.warning(f"Invalid max_age format '{max_age}': {e}") logger.warning(f"Invalid max_age format '{max_age}': {e}")
if not match(ctx, perm): groups = [(p,) if isinstance(p, str) else tuple(p) for p in perm]
ok = match(ctx, perm) if match else permutil.has_all_groups(ctx, groups)
if not ok:
effective_scopes = ( effective_scopes = (
{p.scope for p in (ctx.permissions or [])} {p.scope for p in (ctx.permissions or [])}
if ctx.permissions if ctx.permissions
else set(ctx.role.permissions or []) else set(ctx.role.permissions or [])
) )
missing = sorted(set(perm) - effective_scopes) missing = [
"|".join(g)
for g in groups
if not permutil.group_satisfied(effective_scopes, g)
]
log_permission_denied( log_permission_denied(
ctx, perm, missing, require_all=(match == permutil.has_all) ctx, ["|".join(g) for g in groups], missing, require_all=True
) )
raise AuthException( raise AuthException(
status_code=403, status_code=403,
+91 -3
View File
@@ -1,21 +1,96 @@
"""Minimal permission helpers with '*' wildcard support (no DB expansion).""" """Minimal permission helpers with '*' wildcard support (no DB expansion)."""
import re
from collections.abc import Sequence from collections.abc import Sequence
from fnmatch import fnmatchcase from functools import lru_cache
from paskia.authsession import session_ctx from paskia.authsession import session_ctx
from paskia.util.hostutil import normalize_host from paskia.util.hostutil import normalize_host
__all__ = ["has_any", "has_all", "has_all_scopes", "session_context"] __all__ = [
"group_satisfied",
"has_all",
"has_all_groups",
"has_all_scopes",
"has_all_scopes_groups",
"has_any",
"parse_perm_args",
"session_context",
]
# Characters allowed in a scope pattern within the perm query argument:
# the scope charset (see querysafe) plus the '*' wildcard.
_SCOPE_PATTERN_RE = re.compile(r"^[A-Za-z0-9:._~/*-]+$")
def parse_perm_args(values: Sequence[str]) -> list[tuple[str, ...]]:
"""Parse repeated perm query argument values into groups of alternatives.
Each value holds space-separated groups; each group holds one or more
scope patterns separated by '|'. A group is satisfied when any of its
alternatives matches; all groups must be satisfied (AND semantics).
Extra spaces around groups (leading, trailing, repeated) are tolerated.
Anything else out of spec raises ValueError: empty values, empty
alternatives around '|', characters outside the scope charset and the
'*' wildcard, stray '+' etc.
"""
groups: list[tuple[str, ...]] = []
for value in values:
if not isinstance(value, str) or not value:
raise ValueError("perm value must not be empty")
for group in value.split(" "):
if not group:
# Tolerate extra spaces from URL formatting
continue
alternatives = group.split("|")
for alt in alternatives:
if not alt:
raise ValueError("empty alternative around '|' in perm argument")
if not _SCOPE_PATTERN_RE.match(alt):
raise ValueError("invalid character in perm scope pattern")
groups.append(tuple(alternatives))
return groups
@lru_cache(maxsize=256)
def _pattern_regex(pattern: str) -> re.Pattern:
"""Compile a scope pattern to a regex with filename-like wildcards.
'*' matches any sequence within a single path segment (it crosses
neither ':' nor '/'), '**' matches across separators. Partial segments
may be wildcarded (e.g. 'myapp:re*' or '*:read').
"""
parts = []
i = 0
while i < len(pattern):
if pattern[i] == "*":
if pattern[i + 1 : i + 2] == "*":
parts.append(".*")
i += 2
else:
parts.append("[^:/]*")
i += 1
else:
parts.append(re.escape(pattern[i]))
i += 1
return re.compile("".join(parts))
def _match(perms: set[str], patterns: Sequence[str]): def _match(perms: set[str], patterns: Sequence[str]):
return ( return (
any(fnmatchcase(p, pat) for p in perms) if "*" in pat else pat in perms any(_pattern_regex(pat).fullmatch(p) for p in perms)
if "*" in pat
else pat in perms
for pat in patterns for pat in patterns
) )
def group_satisfied(perms: set[str], group: Sequence[str]) -> bool:
"""Check that at least one alternative in the group matches."""
return any(_match(perms, group))
def _get_effective_scopes(ctx) -> set[str]: def _get_effective_scopes(ctx) -> set[str]:
"""Get effective permission scopes from context. """Get effective permission scopes from context.
@@ -36,11 +111,24 @@ def has_all(ctx, patterns: Sequence[str]) -> bool:
return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
def has_all_groups(ctx, groups: Sequence[Sequence[str]]) -> bool:
"""Check that every group has at least one matching alternative."""
if not ctx:
return False
scopes = _get_effective_scopes(ctx)
return all(group_satisfied(scopes, g) for g in groups)
def has_all_scopes(scopes: set[str], patterns: Sequence[str]) -> bool: def has_all_scopes(scopes: set[str], patterns: Sequence[str]) -> bool:
"""Check that a pre-computed scope set satisfies all required patterns.""" """Check that a pre-computed scope set satisfies all required patterns."""
return all(_match(scopes, patterns)) if patterns else True return all(_match(scopes, patterns)) if patterns else True
def has_all_scopes_groups(scopes: set[str], groups: Sequence[Sequence[str]]) -> bool:
"""Check that a pre-computed scope set satisfies every group of alternatives."""
return all(group_satisfied(scopes, g) for g in groups)
async def session_context(auth: str | None, host: str | None = None): async def session_context(auth: str | None, host: str | None = None):
if not auth: if not auth:
return None return None
+2 -2
View File
@@ -1,11 +1,11 @@
import re import re
_SAFE_RE = re.compile(r"^[A-Za-z0-9:._~-]+$") _SAFE_RE = re.compile(r"^[A-Za-z0-9:._~/-]+$")
def assert_safe(value: str, *, field: str = "value") -> None: def assert_safe(value: str, *, field: str = "value") -> None:
if not isinstance(value, str) or not value or not _SAFE_RE.match(value): if not isinstance(value, str) or not value or not _SAFE_RE.match(value):
raise ValueError(f"{field} must match ^[A-Za-z0-9:._~-]+$") raise ValueError(f"{field} must match ^[A-Za-z0-9:._~/-]+$")
__all__ = ["assert_safe"] __all__ = ["assert_safe"]
+204 -1
View File
@@ -22,7 +22,7 @@ from paskia import authcode
from paskia.authsession import EXPIRES from paskia.authsession import EXPIRES
from paskia.db import delete_session from paskia.db import delete_session
from paskia.db.structs import Client from paskia.db.structs import Client
from paskia.util import avatar, hostutil, oidjwt from paskia.util import avatar, hostutil, oidjwt, permutil
from paskia.util.passphrase import generate from paskia.util.passphrase import generate
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
@@ -233,6 +233,209 @@ class TestForwardEndpoint:
assert data["auth"]["mode"] == "forbidden" assert data["auth"]["mode"] == "forbidden"
class TestPermOrSemantics:
"""Tests for OR ('|') semantics and strict parsing of the perm argument"""
@pytest.mark.asyncio
async def test_or_alternative_matches(
self, client: httpx.AsyncClient, session_token: str
):
"""Group is satisfied when any alternative matches."""
response = await client.post(
"/auth/api/validate?perm=missing:scope|auth:admin",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_or_no_alternative_matches(
self, client: httpx.AsyncClient, session_token: str
):
"""Group fails when no alternative matches."""
response = await client.post(
"/auth/api/validate?perm=missing:a|missing:b",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_or_combined_with_and_group(
self, client: httpx.AsyncClient, session_token: str
):
"""Space-separated groups are ANDed with OR groups."""
response = await client.post(
"/auth/api/validate?perm=auth:admin|missing:a+missing:b",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_multiple_perm_args_and_semantics(
self, client: httpx.AsyncClient, session_token: str
):
"""Repeated perm arguments remain ANDed."""
ok = await client.post(
"/auth/api/validate?perm=auth:admin&perm=auth:admin|missing:a",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert ok.status_code == 200
denied = await client.post(
"/auth/api/validate?perm=auth:admin&perm=missing:a",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert denied.status_code == 403
@pytest.mark.asyncio
async def test_forward_or_semantics(
self, client: httpx.AsyncClient, session_token: str
):
"""Forward endpoint supports OR semantics too."""
response = await client.get(
"/auth/api/forward?perm=missing:a|auth:admin",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 204
@pytest.mark.asyncio
async def test_extra_spaces_tolerated(
self, client: httpx.AsyncClient, session_token: str
):
"""Leading, trailing and repeated spaces between groups are tolerated."""
response = await client.post(
"/auth/api/validate?perm=%20auth:admin%20%20",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_pipe_in_separate_arg_does_not_weaken(
self, client: httpx.AsyncClient, session_token: str
):
"""perm=auth:admin&perm=|bar is a syntax error, not an OR for auth:admin."""
response = await client.post(
"/auth/api/validate?perm=auth:admin&perm=|bar",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_forward_400_identifies_origin_without_echoing_args(
self, client: httpx.AsyncClient, session_token: str
):
"""Forward 400 names the endpoint and does not echo query args."""
response = await client.get(
"/auth/api/forward?perm=a||b",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400
detail = response.json()["detail"]
assert detail.startswith("/auth/api/forward")
assert "a||b" not in detail
@pytest.mark.asyncio
@pytest.mark.parametrize(
"query",
[
"perm=", # empty value
"perm=a||b", # empty alternative
"perm=|a", # leading pipe
"perm=a|", # trailing pipe
"perm=a%20|%20b", # spaces around pipe
"perm=a%2Bb", # percent-encoded plus
"perm=a,b", # character not allowed in scopes
],
)
async def test_invalid_perm_syntax_returns_400(
self, client: httpx.AsyncClient, session_token: str, query: str
):
"""Out-of-spec perm values are rejected with 400, not guessed at."""
for path in ("/auth/api/validate", "/auth/api/forward"):
if path.endswith("validate"):
response = await client.post(
f"{path}?{query}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
else:
response = await client.get(
f"{path}?{query}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400, f"{path}?{query}"
class TestPermParsing:
"""Unit tests for permutil.parse_perm_args"""
def test_single_scope(self):
assert permutil.parse_perm_args(["auth:admin"]) == [("auth:admin",)]
def test_space_separated_groups(self):
assert permutil.parse_perm_args(["a b", "c"]) == [("a",), ("b",), ("c",)]
def test_or_group(self):
assert permutil.parse_perm_args(["a|b c"]) == [("a", "b"), ("c",)]
def test_wildcard_allowed(self):
assert permutil.parse_perm_args(["myapp:*|other"]) == [("myapp:*", "other")]
def test_extra_spaces_tolerated(self):
assert permutil.parse_perm_args([" a b ", "c"]) == [("a",), ("b",), ("c",)]
@pytest.mark.parametrize(
"values",
[
[""],
["a||b"],
["a | b"],
["a| b"],
["a+b"],
["a,b"],
["a\tb"],
],
)
def test_syntax_errors(self, values):
with pytest.raises(ValueError):
permutil.parse_perm_args(values)
class TestPermWildcards:
"""Unit tests for filename-like wildcard semantics in scope patterns"""
@pytest.mark.parametrize(
"pattern,scope,expected",
[
("myapp:*", "myapp:read", True),
("myapp:*", "myapp:read:all", False), # * stays within one element
("myapp:**", "myapp:read:all", True), # ** crosses elements
("myapp:**", "myapp:", True),
("myapp:re*", "myapp:read", True), # partial element, suffix wildcard
("myapp:*ad", "myapp:read", True), # prefix wildcard
("myapp:r*d", "myapp:read", True), # text on both sides
("myapp:r*d", "myapp:redo", False),
("*:read", "myapp:read", True),
("*", "myapp:read", False),
("**", "myapp:read", True),
("myapp:*:all", "myapp:read:all", True),
("myapp:*:all", "myapp:read:write:all", False),
# regex metacharacters valid in scopes are matched literally
("my.app:*", "my.app:read", True),
("my.app:*", "myXapp:read", False),
("myapp:v1.*", "myapp:v1.2", True),
("myapp:v1.*", "myapp:v1x2", False),
("my-app_*:~*", "my-app_x:~tmp", True),
# slash is a literal separator; * crosses neither / nor :
("myapp:path:/api/clients:write", "myapp:path:/api/clients:write", True),
("myapp:path:*", "myapp:path:/api/clients", False),
("myapp:path:**", "myapp:path:/api/clients", True),
("myapp:path:/api/*:write", "myapp:path:/api/clients:write", True),
("myapp:path:/api/*:write", "myapp:path:/api/v2/clients:write", False),
("myapp:path:/api/**:write", "myapp:path:/api/v2/clients:write", True),
],
)
def test_wildcard_matching(self, pattern, scope, expected):
assert permutil.has_all_scopes_groups({scope}, [(pattern,)]) is expected
class TestLogoutEndpoint: class TestLogoutEndpoint:
"""Tests for POST /auth/api/logout""" """Tests for POST /auth/api/logout"""