Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
3.0.29 || 12.08.2026
fix(api): /v3/groups/manages parses list-format roles + cleanup stale app contracts + gate contract endpoints — `get_groups_manages` expected roles as a dict but all groups store roles as a list. Now parses both formats. Added `/v3/app-contracts/cleanup` to tombstone stale contracts where `allowed_origin` is a service name. Gated `/v3/app-contracts/add` and `/revoke` to authenticator origins only (CORS_SERVICE_MANAGERS) — apps cannot directly create or revoke contracts, they must go through the popup consent flow.

3.0.28 || 12.08.2026
fix(api): deduplicate group_contracts in get_user_groups and get_groups_manages — ReplacingMergeTree background merges may not have run, causing duplicate groups and duplicate memberships in the authenticator UI. Both queries now use window functions to deduplicate group_members AND group_contracts by latest updated_at, with a Python set safety net. Also fixed member_count subqueries to deduplicate.

Expand Down
57 changes: 52 additions & 5 deletions api/app/v3/endpoints/contracts.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, Request

from app import settings
from app.v3.endpoints.auth_helper import user as _user
from app.v3.models import AddAppContract, RevokeAppContract
from app.v3.models.common import TokenOnly
Expand All @@ -8,9 +9,30 @@
router = APIRouter(tags=["app-contracts"])


def _is_authenticator_origin(request: Request) -> bool:
"""Check if the request comes from an authenticator host."""
origin = request.headers.get("origin", "")
if not origin:
return True # same-origin or direct API call — allow
try:
host = origin.replace("https://", "").replace("http://", "").split("/")[0].split(":")[0]
except Exception:
return False
return host in settings.CORS_SERVICE_MANAGERS


@router.post("/add")
async def add_app_contract(data: AddAppContract):
"""Add an app contract (one per app, permissions is JSON)."""
async def add_app_contract(request: Request, data: AddAppContract):
"""Add an app contract (one per app, permissions is JSON).

Only callable from an authenticator origin — apps must go through
the popup consent flow, they cannot create contracts directly.
"""
if not _is_authenticator_origin(request):
raise HTTPException(
status_code=403,
detail="App contract creation is only allowed from the authenticator. Apps must go through the consent popup.",
)
user = _user(data)
result = ch.add_app_contract(user, data.allowed_origin, data.permissions)
return result
Expand All @@ -24,11 +46,36 @@ async def get_app_contracts(data: TokenOnly):


@router.post("/revoke")
async def revoke_app_contract(data: RevokeAppContract):
"""Revoke one app contract (by origin) or all."""
async def revoke_app_contract(request: Request, data: RevokeAppContract):
"""Revoke one app contract (by origin) or all.

Only callable from an authenticator origin — apps cannot revoke
contracts directly.
"""
if not _is_authenticator_origin(request):
raise HTTPException(
status_code=403,
detail="App contract revocation is only allowed from the authenticator.",
)
user = _user(data)
if data.allowed_origin:
ch.revoke_app_contract(user, data.allowed_origin)
else:
ch.revoke_all_app_contracts(user)
return {"status": "revoked"}


@router.post("/cleanup")
async def cleanup_stale_contracts(request: Request, data: TokenOnly):
"""Tombstone stale app contracts where allowed_origin is not a URL.

Only callable from an authenticator origin.
"""
if not _is_authenticator_origin(request):
raise HTTPException(
status_code=403,
detail="Contract cleanup is only allowed from the authenticator.",
)
user = _user(data)
count = ch.cleanup_stale_app_contracts(user)
return {"status": "cleaned", "revoked": count}
25 changes: 23 additions & 2 deletions api/app/v3/services/clickhouse.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,23 @@ def revoke_all_app_contracts(user_key: str):
)


def cleanup_stale_app_contracts(user_key: str) -> int:
"""Tombstone app contracts where allowed_origin is not a URL (stale service-name entries).

Legitimate contracts always have allowed_origin starting with http:// or https://.
Returns the number of contracts tombstoned.
"""
result = client.command(
"INSERT INTO app_contracts (user_key, allowed_origin, permissions, created_at, updated_at, deleted) "
"SELECT user_key, allowed_origin, permissions, created_at, now(), 1 "
"FROM app_contracts "
"WHERE user_key = %(user_key)s AND deleted = 0 "
"AND allowed_origin NOT LIKE 'http://%%' AND allowed_origin NOT LIKE 'https://%%'",
{"user_key": user_key},
)
return result.written_rows if hasattr(result, "written_rows") else 0


# ---------------------------------------------------------------------------
# Blacklists
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -796,8 +813,12 @@ def get_groups_manages(member_key: str) -> list[dict]:
continue
seen.add(group_id)
# Check if the user's role has manageRoles permission
roles = _parse_json(roles_json) if roles_json else {}
role_def = roles.get(my_role, {})
# roles_json is a list of {name, services, permissions} — find matching role
roles_list = _parse_json(roles_json) if roles_json else []
if isinstance(roles_list, list):
role_def = next((r for r in roles_list if r.get("name") == my_role), {})
else:
role_def = roles_list.get(my_role, {})
if isinstance(role_def, dict) and "manageRoles" in role_def.get("permissions", []):
out.append(
{
Expand Down
22 changes: 20 additions & 2 deletions api/tests/test_v3_clickhouse.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,15 @@ class TestGetGroupsManages:
def test_has_manage(self):
with _patch_client() as mock_client:
mock_client.query.return_value = _mock_result_rows(
[("g1", "open", '{"admin": {"permissions": ["manageRoles"]}}', "admin", 5)]
[
(
"g1",
"open",
'[{"name": "admin", "services": ["*"], "permissions": ["readAll", "manageRoles"]}]',
"admin",
5,
)
]
)
groups = ch.get_groups_manages("alice")
assert len(groups) == 1
Expand All @@ -573,7 +581,7 @@ def test_has_manage(self):
def test_no_manage(self):
with _patch_client() as mock_client:
mock_client.query.return_value = _mock_result_rows(
[("g1", "open", '{"admin": {"permissions": ["readAll"]}}', "admin", 5)]
[("g1", "open", '[{"name": "admin", "services": ["*"], "permissions": ["readAll"]}]', "admin", 5)]
)
groups = ch.get_groups_manages("alice")
assert len(groups) == 0
Expand All @@ -584,6 +592,16 @@ def test_empty(self):
groups = ch.get_groups_manages("alice")
assert len(groups) == 0

def test_legacy_dict_format(self):
"""Backward compat: old dict-style roles still work."""
with _patch_client() as mock_client:
mock_client.query.return_value = _mock_result_rows(
[("g1", "open", '{"admin": {"permissions": ["manageRoles"]}}', "admin", 5)]
)
groups = ch.get_groups_manages("alice")
assert len(groups) == 1
assert groups[0]["group_id"] == "g1"


# ---------------------------------------------------------------------------
# Provider service contracts
Expand Down
31 changes: 29 additions & 2 deletions api/tests/test_v3_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ def test_add(self, client, token):
"allowed_origin": "myapp.com",
"permissions": {"posts": ["readAll", "create"], "playlists": ["readAll"]},
},
headers={"Origin": "https://auth.localhost"},
)
assert resp.status_code == 200
assert resp.json()["allowed_origin"] == "myapp.com"
Expand All @@ -525,9 +526,22 @@ def test_add_missing_permissions(self, client, token):
"token": token,
"allowed_origin": "myapp.com",
},
headers={"Origin": "https://auth.localhost"},
)
assert resp.status_code == 422

def test_add_rejected_non_authenticator(self, client, token):
resp = client.post(
"/v3/app-contracts/add",
json={
"token": token,
"allowed_origin": "myapp.com",
"permissions": {"posts": ["readAll"]},
},
headers={"Origin": "https://malicious.example.com"},
)
assert resp.status_code == 403

def test_list(self, client, token):
mock_rows = [("myapp.com", '{"posts": ["readAll"]}')]
with patch("app.v3.services.clickhouse.client") as mock_ch:
Expand All @@ -541,12 +555,17 @@ def test_revoke_by_origin(self, client, token):
resp = client.post(
"/v3/app-contracts/revoke",
json={"token": token, "allowed_origin": "myapp.com"},
headers={"Origin": "https://auth.localhost"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "revoked"

def test_revoke_all(self, client, token):
resp = client.post("/v3/app-contracts/revoke", json={"token": token})
resp = client.post(
"/v3/app-contracts/revoke",
json={"token": token},
headers={"Origin": "https://auth.localhost"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "revoked"

Expand Down Expand Up @@ -642,7 +661,15 @@ def test_read_by_id_not_found(self, client, token):

class TestGroupsManages:
def test_manages(self, client, token):
mock_rows = [("g1", "open", '{"admin": {"permissions": ["manageRoles"]}}', "admin", 5)]
mock_rows = [
(
"g1",
"open",
'[{"name": "admin", "services": ["*"], "permissions": ["readAll", "manageRoles"]}]',
"admin",
5,
)
]
with patch("app.v3.services.clickhouse.client") as mock_ch:
mock_ch.query.return_value = MagicMock(result_rows=mock_rows)
resp = client.post("/v3/groups/manages", json={"token": token})
Expand Down
24 changes: 2 additions & 22 deletions marketing/marketing-ui/public/docs/messages/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,6 @@ async function v3Post(action, params = {}) {
return res.json()
}

// v3: add an app contract so this origin can read/write the service
async function ensureAppContract() {
const origin = window.location.origin
try {
const token = document.cookie.match(/token=([^;]+)/)?.[1]
if (!token) return
await fetch(`${API_ORIGIN}/v3/app-contracts/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
allowed_origin: origin,
permissions: { [SERVICE]: ['readAll', 'create', 'updateOwn', 'deleteOwn'] },
}),
})
} catch {
// Contract might already exist — not an error
}
}

// v3: ensure a DM group exists between two users, create if not
// Both users are admins (owner role) of the group
async function ensureDmGroup(myUsername, theirUsername, provider) {
Expand Down Expand Up @@ -106,8 +86,8 @@ function initApp() {
toUsername.value = t.username
toProvider.value = t.provider

// v3: ensure the app contract exists, then load messages
ensureAppContract().then(() => readMessages()).catch(() => readMessages())
// v3: load messages
readMessages()
}

// Self-register in the app store (no auth required)
Expand Down
25 changes: 2 additions & 23 deletions marketing/marketing-ui/public/docs/notes/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,26 +34,6 @@ async function v3Post(action, params = {}) {
return res.json()
}

// v3: add an app contract so this origin can read/write the service
async function ensureAppContract() {
const origin = window.location.origin
try {
const token = document.cookie.match(/token=([^;]+)/)?.[1]
if (!token) return
await fetch(`${API_ORIGIN}/v3/app-contracts/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
allowed_origin: origin,
permissions: { [SERVICE]: ['readAll', 'create', 'updateOwn', 'deleteOwn'] },
}),
})
} catch {
// Contract might already exist — not an error
}
}

// v3: ensure a personal notes group exists, create if not
async function ensureNotesGroup(username, provider) {
const groupName = `notes-${username}`
Expand Down Expand Up @@ -105,9 +85,8 @@ function initApp() {
message.innerHTML = `Signed in as <strong>${t["provider"]}/${t["username"]}</strong>`
editor.style.display = "block"

// v3: ensure the app contract and personal notes group exist, then load notes
ensureAppContract()
.then(() => ensureNotesGroup(t.username, t.provider))
// v3: ensure the personal notes group exists, then load notes
ensureNotesGroup(t.username, t.provider)
.then(() => readNotes())
.catch(() => readNotes())
}
Expand Down
21 changes: 1 addition & 20 deletions marketing/marketing-ui/public/docs/tasks/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,25 +34,6 @@ async function v3Post(action, params = {}) {
return res.json()
}

async function ensureAppContract() {
const origin = window.location.origin
try {
const token = document.cookie.match(/token=([^;]+)/)?.[1]
if (!token) return
await fetch(`${API_ORIGIN}/v3/app-contracts/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
allowed_origin: origin,
permissions: { [SERVICE]: ['readAll', 'create', 'updateOwn', 'deleteOwn'] },
}),
})
} catch {
// Contract might already exist
}
}

let currentGroupId = null

authButton.onclick = () => window.web10.openAuthPortal(AUTH_ORIGIN)
Expand All @@ -68,7 +49,7 @@ function initApp() {
message.innerHTML = `Signed in as <strong>${t["provider"]}/${t["username"]}</strong>`
app.style.display = "block"

ensureAppContract().then(() => readTasks()).catch(() => readTasks())
readTasks()
}

// Self-register in the app store
Expand Down
3 changes: 3 additions & 0 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export type {
V3InviteResponse,
V3JoinRequest,
V3ServiceContract,
V3ACR,
V3GCR,
V3ContractRequest,
V3User,
V3LoginResponse,
} from './v3'
Expand Down
Loading
Loading