Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 52 additions & 0 deletions atr/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ class LdapLookupForm(form.Form):
email: str = form.label("Email address (optional)", "Enter email address, e.g. user@example.org")


class RevokeUserTokensForm(form.Form):
asf_uid: str = form.label("ASF UID", "Enter the ASF UID whose tokens should be revoked.")
confirm_revoke: Literal["REVOKE"] = form.label("Confirmation", "Type REVOKE to confirm.")


class SessionDataCommon(NamedTuple):
uid: str
fullname: str
Expand Down Expand Up @@ -699,6 +704,53 @@ async def projects_update_post(session: web.Committer) -> str | web.WerkzeugResp
}, 200


@admin.get("/revoke-user-tokens")
async def revoke_user_tokens_get(session: web.Committer) -> str:
"""Revoke all Personal Access Tokens for a specified user."""
token_counts: list[tuple[str, int]] = []
async with db.session() as data:
stmt = (
sqlmodel.select(
sql.PersonalAccessToken.asfuid,
sqlmodel.func.count(),
)
.group_by(sql.PersonalAccessToken.asfuid)
.order_by(sql.PersonalAccessToken.asfuid)
)
rows = await data.execute_query(stmt)
token_counts = [(row[0], row[1]) for row in rows]

rendered_form = form.render(
model_cls=RevokeUserTokensForm,
submit_label="Revoke all tokens",
)
return await template.render(
"revoke-user-tokens.html",
form=rendered_form,
token_counts=token_counts,
)


@admin.post("/revoke-user-tokens")
@admin.form(RevokeUserTokensForm)
async def revoke_user_tokens_post(
session: web.Committer, revoke_form: RevokeUserTokensForm
) -> str | web.WerkzeugResponse:
"""Revoke all Personal Access Tokens for a specified user."""
target_uid = revoke_form.asf_uid.strip()

async with storage.write(session) as write:
wafa = write.as_foundation_admin("infrastructure")
count = await wafa.tokens.revoke_all_user_tokens(target_uid)

if count > 0:
await quart.flash(f"Revoked {count} token(s) for {target_uid}.", "success")
else:
await quart.flash(f"No tokens found for {target_uid}.", "info")

return await session.redirect(revoke_user_tokens_get)


@admin.get("/task-times/<project_name>/<version_name>/<revision_number>")
async def task_times(
session: web.Committer, project_name: str, version_name: str, revision_number: str
Expand Down
48 changes: 48 additions & 0 deletions atr/admin/templates/revoke-user-tokens.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{% extends "layouts/base-admin.html" %}

{%- block title -%}Revoke user tokens ~ ATR{%- endblock title -%}

{%- block description -%}Revoke all Personal Access Tokens for a user.{%- endblock description -%}

{% block content %}
<h1>Revoke user tokens</h1>
<p>Revoke all Personal Access Tokens (PATs) for a user account. Use this when an account
is being disabled or when immediate token revocation is needed.</p>

<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Revoke tokens</h5>
</div>
<div class="card-body">
{{ form }}
</div>
</div>

{% if token_counts %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">Users with active tokens</h5>
</div>
<div class="card-body">
<table class="table table-sm table-striped table-bordered">
<thead>
<tr>
<th>ASF UID</th>
<th>Token count</th>
</tr>
</thead>
<tbody>
{% for uid, count in token_counts %}
<tr>
<td><code>{{ uid }}</code></td>
<td>{{ count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="alert alert-info" role="alert">No users currently have active tokens.</div>
{% endif %}
{% endblock content %}
10 changes: 7 additions & 3 deletions atr/docs/authentication-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,13 @@ Committers can obtain PATs from the `/tokens` page on the ATR website. PATs have

* **Validity**: 180 days from creation, while LDAP account is still active
* **Storage**: ATR stores only SHA3-256 hashes, never the plaintext PAT
* **Revocation**: Users can revoke their own PATs at any time; admins can revoke any PAT
* **Revocation**: Users can revoke their own PATs at any time; admins can revoke all PATs for any user via the admin "Revoke user tokens" page
* **Automatic cleanup**: A background loop ([`token_cleanup`](/ref/atr/token_cleanup.py)) polls LDAP approximately every hour and automatically revokes all PATs belonging to banned or deleted accounts
* **Purpose**: PATs are used solely to obtain JWTs; they cannot be used directly for API access

Only authenticated committers (signed in via ASF OAuth) can create PATs. Each user can have multiple active PATs.

PATs are rejected if the user who created them has been removed from LDAP.
PATs are rejected if the user who created them has been banned in or removed from LDAP. This is enforced at three layers: the JWT exchange endpoint checks LDAP status before issuing a JWT (immediate), a background cleanup loop revokes PATs for banned or deleted accounts (within ~1 hour), and administrators can revoke PATs immediately through the admin interface.

### JSON Web Tokens (JWTs)

Expand Down Expand Up @@ -139,7 +140,8 @@ For web users, authentication happens once via ASF OAuth, and the session persis
### Personal Access Tokens

* Stored as SHA3-256 hashes
* Can be revoked immediately by the user
* Can be revoked immediately by the user or in bulk by administrators
* Automatically revoked when the owning account is banned or deleted in LDAP
* Limited purpose (only for JWT issuance) reduces impact of compromise
* Long validity (180 days) balanced by easy revocation

Expand All @@ -162,3 +164,5 @@ Tokens must be protected by the user at all times:

* [`principal.py`](/ref/atr/principal.py) - Session caching and authorization data
* [`jwtoken.py`](/ref/atr/jwtoken.py) - JWT creation, verification, and decorators
* [`token_cleanup.py`](/ref/atr/token_cleanup.py) - Automated PAT revocation for banned/deleted accounts
* [`storage/writers/tokens.py`](/ref/atr/storage/writers/tokens.py) - Token creation, deletion, and admin revocation
11 changes: 11 additions & 0 deletions atr/docs/authorization-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,21 @@ Token operations apply to the authenticated user:
* Allowed for: The token owner, or administrators
* Constraint: Users can only revoke their own tokens (unless admin)

**Revoke all tokens for a user (admin)**:

* Allowed for: ATR administrators only
* Interface: Admin "Revoke user tokens" page
* Constraint: Requires typing "REVOKE" as confirmation

**Automated token revocation**:

* The [`token_cleanup`](/ref/atr/token_cleanup.py) background loop runs approximately every hour, queries LDAP for all users who hold PATs, and revokes tokens belonging to accounts that are banned or deleted. This runs without human intervention and is audit logged.

**Exchange PAT for JWT**:

* Allowed for: Anyone with a valid PAT
* Note: This is an unauthenticated endpoint; the PAT serves as the credential
* Constraint: LDAP is checked at exchange time; banned or deleted accounts are rejected even if the PAT has not yet been cleaned up

## Access control for check ignores

Expand Down
9 changes: 9 additions & 0 deletions atr/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
import atr.svn.pubsub as pubsub
import atr.tasks as tasks
import atr.template as template
import atr.token_cleanup as token_cleanup
import atr.user as user
import atr.util as util

Expand Down Expand Up @@ -269,6 +270,9 @@ async def startup() -> None:
admins_task = asyncio.create_task(cache.admins_refresh_loop())
app.extensions["admins_task"] = admins_task

pat_cleanup_task = asyncio.create_task(token_cleanup.cleanup_loop())
app.extensions["pat_cleanup_task"] = pat_cleanup_task

worker_manager = manager.get_worker_manager()
await worker_manager.start()

Expand Down Expand Up @@ -312,6 +316,11 @@ async def shutdown() -> None:
with contextlib.suppress(asyncio.CancelledError):
await task

if task := app.extensions.get("pat_cleanup_task"):
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task

await db.shutdown_database()

await _app_shutdown_log_listeners(app)
Expand Down
1 change: 1 addition & 0 deletions atr/storage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ def __init__(self, write: Write, data: db.Session, committee_name: str):
self.__committee_name = committee_name
self.keys = writers.keys.FoundationAdmin(write, self, data, committee_name)
self.release = writers.release.FoundationAdmin(write, self, data, committee_name)
self.tokens = writers.tokens.FoundationAdmin(write, self, data, committee_name)

@property
def asf_uid(self) -> str:
Expand Down
31 changes: 31 additions & 0 deletions atr/storage/writers/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,34 @@ def __init__(
raise storage.AccessError("Not authorized")
self.__asf_uid = asf_uid
self.__committee_name = committee_name


class FoundationAdmin(CommitteeMember):
def __init__(
self,
write: storage.Write,
write_as: storage.WriteAsFoundationAdmin,
data: db.Session,
committee_name: str,
):
super().__init__(write, write_as, data, committee_name)
self.__write = write
self.__write_as = write_as
self.__data = data

async def revoke_all_user_tokens(self, target_asf_uid: str) -> int:
"""Revoke all PATs for a specified user. Returns count of revoked tokens."""
tokens = await self.__data.query_all(
sqlmodel.select(sql.PersonalAccessToken).where(sql.PersonalAccessToken.asfuid == target_asf_uid)
)
count = len(tokens)
for token in tokens:
await self.__data.delete(token)

if count > 0:
await self.__data.commit()
self.__write_as.append_to_audit_log(
target_asf_uid=target_asf_uid,
tokens_revoked=count,
)
return count
5 changes: 5 additions & 0 deletions atr/templates/includes/topnav.html
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,11 @@
href="{{ as_url(admin.ldap_get) }}"
{% if request.endpoint == 'atr_admin_ldap_get' %}class="active"{% endif %}><i class="bi bi-person-plus"></i> LDAP search</a>
</li>
<li>
<a class="dropdown-item"
href="{{ as_url(admin.revoke_user_tokens_get) }}"
{% if request.endpoint == 'atr_admin_revoke_user_tokens_get' %}class="active"{% endif %}><i class="bi bi-shield-x"></i> Revoke user tokens</a>
</li>
<li>
<a class="dropdown-item"
href="{{ as_url(admin.toggle_view_get) }}"
Expand Down
92 changes: 92 additions & 0 deletions atr/token_cleanup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

"""Periodic cleanup of Personal Access Tokens for banned or deleted accounts."""

import asyncio
from typing import Final

import sqlalchemy
import sqlmodel

import atr.db as db
import atr.ldap as ldap
import atr.log as log
import atr.models.sql as sql
import atr.storage as storage

# ~1 hour, deliberately offset from the admin poll interval (3631s)
# to avoid simultaneous LDAP request spikes
POLL_INTERVAL_SECONDS: Final[int] = 3617


async def cleanup_loop() -> None:
"""Periodically revoke PATs belonging to banned or deleted LDAP accounts."""
while True:
await asyncio.sleep(POLL_INTERVAL_SECONDS)
try:
await revoke_pats_for_banned_users()
except Exception as e:
log.warning(f"PAT banned-user cleanup failed: {e}")


async def revoke_pats_for_banned_users() -> int:
"""Check all PAT-holding users against LDAP and revoke tokens for banned/deleted accounts.

Returns the total number of tokens revoked.
"""
# Step 1: Get distinct UIDs that have PATs
async with db.session() as data:
stmt = sqlmodel.select(sql.PersonalAccessToken.asfuid).distinct()
rows = await data.execute_query(stmt)
uids_with_pats = [row[0] for row in rows]

if not uids_with_pats:
return 0

# Step 2: Check each against LDAP, revoke if banned/deleted
revoked_total = 0
for uid in uids_with_pats:
try:
account = await ldap.account_lookup(uid)
if (account is not None) and (not ldap.is_banned(account)):
continue

# Account is gone or banned — delete all their tokens
async with db.session() as data:
delete_stmt = sqlalchemy.delete(sql.PersonalAccessToken).where(sql.PersonalAccessToken.asfuid == uid)
result = await data.execute_query(delete_stmt)
await data.commit()
count: int = getattr(result, "rowcount", 0)

if count > 0:
storage.audit(
target_asf_uid=uid,
tokens_revoked=count,
reason="account_banned_or_deleted",
source="pat_cleanup_loop",
)
log.info(f"Auto-revoked {count} PAT(s) for banned/deleted user {uid}")
revoked_total += count

except Exception as e:
log.warning(f"PAT cleanup: failed to check/revoke for {uid}: {e}")

if revoked_total > 0:
log.info(f"PAT cleanup cycle complete: revoked {revoked_total} total token(s)")

return revoked_total
16 changes: 16 additions & 0 deletions tests/e2e/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
Loading