1
0
mirror of https://github.com/quay/quay.git synced 2025-11-17 23:02:34 +03:00
Files
quay/endpoints/api/test/test_appspecifictoken.py
Dave O'Connor 2511b45e89 fix(api): superuser panel access without SUPERUSERS_FULL_ACCESS (PROJQUAY-9693) (#4455)
fix(api): implement proper superuser permission model and fix access controls

Fixes multiple issues with superuser functionality and implements a comprehensive
permission model for FEATURE_SUPERUSERS_FULL_ACCESS:

**Permission Model:**
- Global Readonly Superusers (auditors): Always have read access to all content,
  independent of FEATURE_SUPERUSERS_FULL_ACCESS setting
- Regular Superusers: Can access /v1/superuser endpoints and their own content.
  Require FEATURE_SUPERUSERS_FULL_ACCESS=true for cross-namespace read access
- Full Access Superusers: Regular superusers with FULL_ACCESS enabled, can
  perform CRUD on content they don't own
- Write operations: Only allowed for full access superusers (global readonly
  superusers never get write access)

**Key Fixes:**
1. Fixed superuser panel endpoints returning 403 when FULL_ACCESS was disabled.
   Basic panel operations (user list, logs, org list, messages) now work with
   just FEATURE_SUPER_USERS enabled.

2. Updated decorators to properly differentiate between basic superuser
   operations and permission bypass operations.

3. Implemented license bypass: Superusers with FULL_ACCESS now bypass
   license/quota limits when creating or modifying private repositories.

4. Fixed 18 permission checks across 7 files to properly implement cross-namespace
   access controls for different superuser types.

**Changes:**
- endpoints/api/__init__.py: Fixed allow_if_superuser(), require_repo_permission, and decorators
- endpoints/api/superuser.py: Updated SuperUserAppTokens permission check
- endpoints/api/organization.py: Updated 4 GET endpoints to require FULL_ACCESS
- endpoints/api/namespacequota.py: Updated 2 GET endpoints to require FULL_ACCESS
- endpoints/api/team.py: Updated 2 GET endpoints to require FULL_ACCESS
- endpoints/api/prototype.py: Updated 1 GET endpoint to require FULL_ACCESS
- endpoints/api/policy.py: Updated auto-prune policy endpoints
- endpoints/api/robot.py: Updated robot endpoints
- endpoints/api/build.py: Updated repository build logs
- endpoints/api/repository.py: Added license bypass for superusers with FULL_ACCESS
- endpoints/api/repository_models_pre_oci.py: Updated repository visibility query
- endpoints/api/logs.py: Fixed log access to require FULL_ACCESS for permission bypass
- endpoints/api/test/test_superuser_full_access.py: Added comprehensive test suite
- endpoints/api/test/test_appspecifictoken.py: Updated test mocking and added 403 test
- test/test_api_usage.py: Updated test expectations for license bypass behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 09:38:11 -05:00

414 lines
18 KiB
Python

from datetime import datetime, timedelta
from unittest.mock import patch
from data import model
from endpoints.api.appspecifictokens import AppToken, AppTokens
from endpoints.api.superuser import SuperUserAppTokens
from endpoints.api.test.shared import conduct_api_call
from endpoints.test.shared import client_with_identity
from test.fixtures import *
def test_app_specific_tokens(app):
with client_with_identity("devtable", app) as cl:
# Add an app specific token.
token_data = {"title": "Testing 123"}
resp = conduct_api_call(cl, AppTokens, "POST", None, token_data, 200).json
token_uuid = resp["token"]["uuid"]
assert "token_code" in resp["token"]
# List the tokens and ensure we have the one added.
resp = conduct_api_call(cl, AppTokens, "GET", None, None, 200).json
assert len(resp["tokens"])
assert token_uuid in set([token["uuid"] for token in resp["tokens"]])
assert not set([token["token_code"] for token in resp["tokens"] if "token_code" in token])
# List the tokens expiring soon and ensure the one added is not present.
resp = conduct_api_call(cl, AppTokens, "GET", {"expiring": True}, None, 200).json
assert token_uuid not in set([token["uuid"] for token in resp["tokens"]])
# Get the token and ensure we have its code (owner can see secret).
resp = conduct_api_call(cl, AppToken, "GET", {"token_uuid": token_uuid}, None, 200).json
assert resp["token"]["uuid"] == token_uuid
assert "token_code" in resp["token"]
# Delete the token.
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": token_uuid}, None, 204)
# Ensure the token no longer exists.
resp = conduct_api_call(cl, AppTokens, "GET", None, None, 200).json
assert len(resp["tokens"])
assert token_uuid not in set([token["uuid"] for token in resp["tokens"]])
conduct_api_call(cl, AppToken, "GET", {"token_uuid": token_uuid}, None, 404)
def test_delete_expired_app_token(app):
user = model.user.get_user("devtable")
expiration = datetime.now() - timedelta(seconds=10)
token = model.appspecifictoken.create_token(user, "some token", expiration)
with client_with_identity("devtable", app) as cl:
# Delete the token.
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": token.uuid}, None, 204)
def test_list_tokens_user_scoped(app):
"""Test that all user types only see their own tokens on /v1/user/apptoken
Tests regular users, superusers, and global readonly superusers to ensure
proper token scoping - users should only see their own tokens on this endpoint.
"""
# Test freshuser (regular user)
freshuser_token_data = {"title": "Freshuser Test Token"}
with client_with_identity("freshuser", app) as cl:
# Create a token for this user
resp = conduct_api_call(cl, AppTokens, "POST", None, freshuser_token_data, 200).json
freshuser_token_uuid = resp["token"]["uuid"]
# List tokens - freshuser should see their own token
resp = conduct_api_call(cl, AppTokens, "GET", None, None, 200).json
token_uuids = set([token["uuid"] for token in resp["tokens"]])
assert freshuser_token_uuid in token_uuids
# Clean up
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": freshuser_token_uuid}, None, 204)
# Test devtable (superuser)
devtable_token_data = {"title": "Devtable Test Token"}
with client_with_identity("devtable", app) as cl:
# Create a token for this user
resp = conduct_api_call(cl, AppTokens, "POST", None, devtable_token_data, 200).json
devtable_token_uuid = resp["token"]["uuid"]
# List tokens - superuser should see their own token but not freshuser's on /v1/user/apptoken
resp = conduct_api_call(cl, AppTokens, "GET", None, None, 200).json
token_uuids = set([token["uuid"] for token in resp["tokens"]])
assert devtable_token_uuid in token_uuids
assert freshuser_token_uuid not in token_uuids
# Clean up
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": devtable_token_uuid}, None, 204)
# Test globalreadonlysuperuser (global readonly superuser)
global_ro_token_data = {"title": "Global RO Test Token"}
with client_with_identity("globalreadonlysuperuser", app) as cl:
# Create a token for this user
resp = conduct_api_call(cl, AppTokens, "POST", None, global_ro_token_data, 200).json
global_ro_token_uuid = resp["token"]["uuid"]
# List tokens - global readonly superuser should see their own token but not others'
resp = conduct_api_call(cl, AppTokens, "GET", None, None, 200).json
token_uuids = set([token["uuid"] for token in resp["tokens"]])
assert global_ro_token_uuid in token_uuids
assert freshuser_token_uuid not in token_uuids
assert devtable_token_uuid not in token_uuids
# Clean up
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": global_ro_token_uuid}, None, 204)
def test_list_expiring_tokens_user_scoped(app):
"""Test that expiring token filtering is properly scoped for all user types
Tests that the expiring=True parameter only returns expiring tokens belonging
to the authenticated user, regardless of user type (regular, superuser, global readonly).
"""
soon_expiration = datetime.now() + timedelta(minutes=1)
far_expiration = datetime.now() + timedelta(days=30)
# Test freshuser (regular user)
with client_with_identity("freshuser", app) as cl:
# Create expiring and non-expiring tokens via API
expiring_resp = conduct_api_call(
cl, AppTokens, "POST", None, {"title": "Freshuser Expiring"}, 200
).json
expiring_uuid = expiring_resp["token"]["uuid"]
normal_resp = conduct_api_call(
cl, AppTokens, "POST", None, {"title": "Freshuser Normal"}, 200
).json
normal_uuid = normal_resp["token"]["uuid"]
# Update expiration times directly on the tokens
expiring_token = model.appspecifictoken.AppSpecificAuthToken.get(
model.appspecifictoken.AppSpecificAuthToken.uuid == expiring_uuid
)
expiring_token.expiration = soon_expiration
expiring_token.save()
normal_token = model.appspecifictoken.AppSpecificAuthToken.get(
model.appspecifictoken.AppSpecificAuthToken.uuid == normal_uuid
)
normal_token.expiration = far_expiration
normal_token.save()
# Query with expiring=True - should only see expiring token
resp = conduct_api_call(cl, AppTokens, "GET", {"expiring": True}, None, 200).json
token_uuids = set([token["uuid"] for token in resp["tokens"]])
assert expiring_uuid in token_uuids
assert normal_uuid not in token_uuids
# Clean up
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": expiring_uuid}, None, 204)
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": normal_uuid}, None, 204)
# Test devtable (superuser)
with client_with_identity("devtable", app) as cl:
# Create expiring and non-expiring tokens via API
expiring_resp = conduct_api_call(
cl, AppTokens, "POST", None, {"title": "Devtable Expiring"}, 200
).json
expiring_uuid = expiring_resp["token"]["uuid"]
normal_resp = conduct_api_call(
cl, AppTokens, "POST", None, {"title": "Devtable Normal"}, 200
).json
normal_uuid = normal_resp["token"]["uuid"]
# Update expiration times
expiring_token = model.appspecifictoken.AppSpecificAuthToken.get(
model.appspecifictoken.AppSpecificAuthToken.uuid == expiring_uuid
)
expiring_token.expiration = soon_expiration
expiring_token.save()
normal_token = model.appspecifictoken.AppSpecificAuthToken.get(
model.appspecifictoken.AppSpecificAuthToken.uuid == normal_uuid
)
normal_token.expiration = far_expiration
normal_token.save()
# Query with expiring=True - superuser should only see their own expiring token
resp = conduct_api_call(cl, AppTokens, "GET", {"expiring": True}, None, 200).json
token_uuids = set([token["uuid"] for token in resp["tokens"]])
assert expiring_uuid in token_uuids
assert normal_uuid not in token_uuids
# Clean up
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": expiring_uuid}, None, 204)
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": normal_uuid}, None, 204)
# Test globalreadonlysuperuser (global readonly superuser)
with client_with_identity("globalreadonlysuperuser", app) as cl:
# Create expiring and non-expiring tokens via API
expiring_resp = conduct_api_call(
cl, AppTokens, "POST", None, {"title": "Global RO Expiring"}, 200
).json
expiring_uuid = expiring_resp["token"]["uuid"]
normal_resp = conduct_api_call(
cl, AppTokens, "POST", None, {"title": "Global RO Normal"}, 200
).json
normal_uuid = normal_resp["token"]["uuid"]
# Update expiration times
expiring_token = model.appspecifictoken.AppSpecificAuthToken.get(
model.appspecifictoken.AppSpecificAuthToken.uuid == expiring_uuid
)
expiring_token.expiration = soon_expiration
expiring_token.save()
normal_token = model.appspecifictoken.AppSpecificAuthToken.get(
model.appspecifictoken.AppSpecificAuthToken.uuid == normal_uuid
)
normal_token.expiration = far_expiration
normal_token.save()
# Query with expiring=True - global RO superuser should only see their own expiring token
resp = conduct_api_call(cl, AppTokens, "GET", {"expiring": True}, None, 200).json
token_uuids = set([token["uuid"] for token in resp["tokens"]])
assert expiring_uuid in token_uuids
assert normal_uuid not in token_uuids
# Clean up
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": expiring_uuid}, None, 204)
conduct_api_call(cl, AppToken, "DELETE", {"token_uuid": normal_uuid}, None, 204)
def test_list_tokens_no_token_codes(app):
"""Test that token codes are never included in list responses"""
devtable_user = model.user.get_user("devtable")
token = model.appspecifictoken.create_token(devtable_user, "Test Token")
try:
with client_with_identity("devtable", app) as cl:
# List tokens should never include token codes
resp = conduct_api_call(cl, AppTokens, "GET", None, None, 200).json
for token_data in resp["tokens"]:
assert "token_code" not in token_data
assert "uuid" in token_data
assert "title" in token_data
assert "last_accessed" in token_data
assert "created" in token_data
assert "expiration" in token_data
finally:
# Clean up
token.delete_instance()
def test_individual_token_access_regular_user(app):
"""Test that regular users can only access their own tokens on /v1/user/apptoken"""
freshuser = model.user.get_user("freshuser")
reader_user = model.user.get_user("reader")
freshuser_token = model.appspecifictoken.create_token(freshuser, "Freshuser Token")
reader_token = model.appspecifictoken.create_token(reader_user, "Reader Token")
try:
with client_with_identity("freshuser", app) as cl:
# Should be able to access own token with secret
resp = conduct_api_call(
cl, AppToken, "GET", {"token_uuid": freshuser_token.uuid}, None, 200
).json
assert resp["token"]["uuid"] == freshuser_token.uuid
assert "token_code" in resp["token"]
# Should NOT be able to access other user's token
conduct_api_call(cl, AppToken, "GET", {"token_uuid": reader_token.uuid}, None, 404)
finally:
freshuser_token.delete_instance()
reader_token.delete_instance()
def test_individual_token_access_superuser(app):
"""Test that superusers can only access their own tokens on /v1/user/apptoken"""
devtable_user = model.user.get_user("devtable")
freshuser = model.user.get_user("freshuser")
devtable_token = model.appspecifictoken.create_token(devtable_user, "DevTable Token")
freshuser_token = model.appspecifictoken.create_token(freshuser, "Freshuser Token")
try:
with client_with_identity("devtable", app) as cl:
# Superuser should be able to access own token with secret on /v1/user/apptoken
resp = conduct_api_call(
cl, AppToken, "GET", {"token_uuid": devtable_token.uuid}, None, 200
).json
assert resp["token"]["uuid"] == devtable_token.uuid
assert "token_code" in resp["token"]
# Superuser should NOT be able to access other user's token on /v1/user/apptoken
conduct_api_call(cl, AppToken, "GET", {"token_uuid": freshuser_token.uuid}, None, 404)
finally:
devtable_token.delete_instance()
freshuser_token.delete_instance()
def test_individual_token_access_global_readonly_superuser(app):
"""Test that global readonly superusers can only access their own tokens on /v1/user/apptoken"""
devtable_user = model.user.get_user("devtable")
global_ro_user = model.user.get_user("globalreadonlysuperuser")
devtable_token = model.appspecifictoken.create_token(devtable_user, "DevTable Token")
global_ro_token = model.appspecifictoken.create_token(global_ro_user, "Global RO Token")
try:
with client_with_identity("globalreadonlysuperuser", app) as cl:
# Global RO superuser should be able to access own token with secret
resp = conduct_api_call(
cl, AppToken, "GET", {"token_uuid": global_ro_token.uuid}, None, 200
).json
assert resp["token"]["uuid"] == global_ro_token.uuid
assert "token_code" in resp["token"]
# Global RO superuser should NOT be able to access other user's token on /v1/user/apptoken
conduct_api_call(cl, AppToken, "GET", {"token_uuid": devtable_token.uuid}, None, 404)
finally:
devtable_token.delete_instance()
global_ro_token.delete_instance()
def test_superuser_endpoint_sees_all_tokens(app):
"""Test that superusers and global readonly superusers can see all tokens on /v1/superuser/apptokens"""
devtable_user = model.user.get_user("devtable")
reader_user = model.user.get_user("reader")
devtable_token = model.appspecifictoken.create_token(devtable_user, "DevTable Token")
reader_token = model.appspecifictoken.create_token(reader_user, "Reader Token")
try:
# Test superuser
with client_with_identity("devtable", app) as cl:
# On /v1/superuser/apptokens, superuser should see all tokens
resp = conduct_api_call(cl, SuperUserAppTokens, "GET", None, None, 200).json
token_uuids = set([token["uuid"] for token in resp["tokens"]])
assert devtable_token.uuid in token_uuids
assert reader_token.uuid in token_uuids
for token in resp["tokens"]:
assert "token_code" not in token
# Test global readonly superuser
# Mock global readonly superuser by mocking the permission functions
with patch(
"endpoints.api.superuser.allow_if_global_readonly_superuser", return_value=True
), patch("endpoints.api.superuser.allow_if_superuser_with_full_access", return_value=False):
with client_with_identity("reader", app) as cl:
# On /v1/superuser/apptokens, global readonly superuser should see all tokens
resp = conduct_api_call(cl, SuperUserAppTokens, "GET", None, None, 200).json
token_uuids = set([token["uuid"] for token in resp["tokens"]])
assert devtable_token.uuid in token_uuids
assert reader_token.uuid in token_uuids
for token in resp["tokens"]:
assert "token_code" not in token
finally:
# Clean up
devtable_token.delete_instance()
reader_token.delete_instance()
def test_superuser_endpoint_requires_full_access(app):
"""Test that regular superusers without FULL_ACCESS get 403 on /v1/superuser/apptokens"""
# Mock a regular superuser (not global readonly) without FULL_ACCESS
with patch(
"endpoints.api.superuser.allow_if_global_readonly_superuser", return_value=False
), patch("endpoints.api.superuser.allow_if_superuser_with_full_access", return_value=False):
with client_with_identity("devtable", app) as cl:
# Regular superuser without FULL_ACCESS should get 403
conduct_api_call(cl, SuperUserAppTokens, "GET", None, None, 403)
def test_superuser_endpoint_expiring_tokens(app):
"""Test expiring token filtering on /v1/superuser/apptokens"""
devtable_user = model.user.get_user("devtable")
reader_user = model.user.get_user("reader")
# Create expiring and non-expiring tokens for both users
soon_expiration = datetime.now() + timedelta(minutes=1)
far_expiration = datetime.now() + timedelta(days=30)
devtable_expiring = model.appspecifictoken.create_token(
devtable_user, "DevTable Expiring", soon_expiration
)
devtable_normal = model.appspecifictoken.create_token(
devtable_user, "DevTable Normal", far_expiration
)
reader_expiring = model.appspecifictoken.create_token(
reader_user, "Reader Expiring", soon_expiration
)
reader_normal = model.appspecifictoken.create_token(
reader_user, "Reader Normal", far_expiration
)
try:
with client_with_identity("devtable", app) as cl:
# On /v1/superuser/apptokens with expiring=True, should see all expiring tokens
resp = conduct_api_call(
cl, SuperUserAppTokens, "GET", {"expiring": True}, None, 200
).json
token_uuids = set([token["uuid"] for token in resp["tokens"]])
# Should see expiring tokens from both users
assert devtable_expiring.uuid in token_uuids
assert reader_expiring.uuid in token_uuids
# Should not see non-expiring tokens
assert devtable_normal.uuid not in token_uuids
assert reader_normal.uuid not in token_uuids
finally:
# Clean up
devtable_expiring.delete_instance()
devtable_normal.delete_instance()
reader_expiring.delete_instance()
reader_normal.delete_instance()