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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ jobs:
- name: Mypy
run: uv run mypy .

- name: Export public API snapshot
run: |
mkdir -p build/api
uv run --locked python scripts/export_openapi.py build/api/openapi.json
uv run --locked python scripts/export_openapi.py build/api/openapi.json --check

- name: Upload public API snapshot
uses: actions/upload-artifact@v4
with:
name: server-openapi-${{ github.sha }}
path: build/api/openapi.json
if-no-files-found: error
retention-days: 14

- name: Pytest
env:
PAPYRUS_TEST_TIMING: build/test-results/timing.json
Expand Down
142 changes: 142 additions & 0 deletions alembic/versions/d1e2f3456789_email_verification_evidence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Bind verification to email and retain only attributable legacy evidence.

Password-only verified accounts retain Papyrus evidence. Matching retained Google
Gmail identities retain Google evidence. Other unattributable booleans need proof. No users,
credentials or library data are deleted. The activation audit reports the count
of previously verified accounts that now need proof. Rollback must fix forward.
"""

import logging
from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = "d1e2f3456789"
down_revision: str | Sequence[str] | None = "c0d1e2f34567"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

TRUSTED_PASSWORD = """
primary_email_verified = true AND primary_email IS NOT NULL
AND EXISTS (SELECT 1 FROM password_credentials p WHERE p.user_id = users.user_id)
AND NOT EXISTS (SELECT 1 FROM user_identities i WHERE i.user_id = users.user_id)
"""


TRUSTED_GOOGLE = """
primary_email_verified = true AND primary_email IS NOT NULL
AND EXISTS (
SELECT 1 FROM user_identities i WHERE i.user_id = users.user_id
AND i.provider = 'google' AND i.provider_subject <> ''
AND i.email_at_provider = users.primary_email
AND i.email_at_provider LIKE '%@gmail.com'
)
"""

TRUSTED_LEGACY = f"({TRUSTED_PASSWORD}) OR ({TRUSTED_GOOGLE})"


def upgrade() -> None:
for name, kind in (
("verified_email", sa.String(255)),
("verification_source", sa.String(32)),
("verification_subject", sa.String(255)),
("verified_at", sa.DateTime(timezone=True)),
):
op.add_column("users", sa.Column(name, kind, nullable=True))

for table in ("user_identities", "auth_exchange_codes"):
op.add_column(table, sa.Column("hosted_domain", sa.String(255), nullable=True))

op.add_column("email_action_tokens", sa.Column("email", sa.String(255), nullable=True))

affected = op.get_bind().scalar(
sa.text(f"SELECT count(*) FROM users WHERE primary_email_verified = true AND NOT ({TRUSTED_LEGACY})"),
)

logging.getLogger("alembic.runtime.migration").info(
"Verification evidence audit: %s accounts require fresh proof", affected
)

op.execute(
sa.text(
f"UPDATE users SET verified_email = primary_email, verification_source = 'papyrus_email', "
f"verified_at = CURRENT_TIMESTAMP WHERE {TRUSTED_PASSWORD}",
),
)

op.execute(
sa.text(
f"""
UPDATE users SET verified_email = primary_email, verification_source = 'google',
verification_subject = (
SELECT i.provider_subject FROM user_identities i
WHERE i.user_id = users.user_id AND i.provider = 'google'
AND i.email_at_provider = users.primary_email AND i.provider_subject <> ''
ORDER BY i.created_at, i.identity_id LIMIT 1
),
verified_at = CURRENT_TIMESTAMP
WHERE {TRUSTED_GOOGLE}
""",
),
)

op.execute("UPDATE users SET primary_email_verified = false WHERE verified_email IS NULL")
op.execute("UPDATE email_action_tokens SET used_at = CURRENT_TIMESTAMP WHERE action_type = 'verify_email'")

op.execute(
"UPDATE auth_sessions SET revoked_at = CURRENT_TIMESTAMP WHERE revoked_at IS NULL "
"AND user_id IN (SELECT user_id FROM users WHERE verified_email IS NULL)",
)

op.create_table(
"verification_recipients",
sa.Column("email", sa.String(255), primary_key=True),
sa.Column("last_queued_at", sa.DateTime(timezone=True), nullable=False),
)

op.create_table(
"verification_deliveries",
sa.Column(
"token_id", sa.Uuid(), sa.ForeignKey("email_action_tokens.token_id", ondelete="CASCADE"), primary_key=True
),
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False),
sa.Column("recipient", sa.String(255), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("attempts", sa.Integer(), server_default=sa.text("0"), nullable=False),
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
)

op.create_index("ix_verification_deliveries_user_id", "verification_deliveries", ["user_id"])
op.create_index("ix_verification_deliveries_next_attempt_at", "verification_deliveries", ["next_attempt_at"])

op.execute(
"""
DO $$
DECLARE permission record;
BEGIN
REVOKE ALL ON TABLE verification_deliveries, verification_recipients FROM PUBLIC;
FOR permission IN
SELECT DISTINCT relation.relname, role.rolname
FROM pg_class relation
JOIN pg_namespace namespace ON namespace.oid = relation.relnamespace
CROSS JOIN LATERAL aclexplode(relation.relacl) acl
JOIN pg_roles role ON role.oid = acl.grantee
WHERE namespace.nspname = current_schema()
AND relation.relname IN ('verification_deliveries', 'verification_recipients')
AND acl.grantee <> relation.relowner
LOOP
EXECUTE format('REVOKE ALL ON TABLE %I FROM %I CASCADE', permission.relname, permission.rolname);
END LOOP;
END $$
""",
)


def downgrade() -> None:
raise RuntimeError(
"Verification enforcement must be retained. Fix forward without removing evidence or pending accounts."
)
57 changes: 57 additions & 0 deletions alembic/versions/d2f3456789ab_simplify_verification_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Keep one account verification state and derive outbox metadata from its token.

Retain the preceding revision so already-applied review/staging databases can
upgrade without losing evidence, pending deliveries, accounts or library data.
"""

from collections.abc import Sequence

from alembic import op

revision: str = "d2f3456789ab"
down_revision: str | Sequence[str] | None = "d1e2f3456789"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.execute(
"""
UPDATE users SET verified_at = NULL, verification_source = NULL
WHERE (
primary_email_verified AND primary_email IS NOT NULL
AND verified_email = primary_email AND verified_at IS NOT NULL
AND verification_source IN ('papyrus_email', 'google')
AND (verification_source <> 'google' OR verification_subject IS NOT NULL)
) IS NOT TRUE
""",
)

op.execute(
"UPDATE auth_sessions SET revoked_at = CURRENT_TIMESTAMP WHERE revoked_at IS NULL "
"AND user_id IN (SELECT user_id FROM users WHERE verified_at IS NULL)",
)

op.alter_column("users", "verified_at", new_column_name="email_verified_at")
op.alter_column("users", "verification_source", new_column_name="email_verification_source")

for column in ("primary_email_verified", "verified_email", "verification_subject"):
op.drop_column("users", column)

op.create_check_constraint(
"ck_users_email_verification",
"users",
"(email_verified_at IS NULL AND email_verification_source IS NULL) OR "
"(primary_email IS NOT NULL AND email_verified_at IS NOT NULL "
"AND email_verification_source IS NOT NULL "
"AND email_verification_source IN ('papyrus_email', 'google'))",
)

op.drop_index("ix_verification_deliveries_user_id", table_name="verification_deliveries")

for column in ("user_id", "recipient", "expires_at"):
op.drop_column("verification_deliveries", column)


def downgrade() -> None:
raise RuntimeError("Retain verification enforcement and pending deliveries. Fix forward.")
12 changes: 9 additions & 3 deletions frontend/dev-pages/src/pages/auth-sandbox/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ interface CurrentUser {
display_name?: string;
}

interface PendingVerificationPayload {
status: "verification_required";
email: string;
message: string;
resend_after: number;
}

interface AuthTokensPayload {
access_token?: string;
refresh_token?: string;
Expand Down Expand Up @@ -349,7 +356,7 @@ async function handleOAuthReturn(): Promise<void> {
function registerHandlers(): void {
refs.register.onclick = async () => {
await runButtonAction("register", refs.register, "Registering", "Registering user", async () => {
const { response, body } = await callSandboxApi<AuthTokensPayload>(config.registerUrl, {
const { response, body } = await callSandboxApi<PendingVerificationPayload>(config.registerUrl, {
method: "POST",
body: JSON.stringify({
email: refs.email.value,
Expand All @@ -361,8 +368,7 @@ function registerHandlers(): void {
});

if (response.ok && body && typeof body === "object") {
applyTokens(body as AuthTokensPayload);
markOutcome("success", "Registration succeeded and tokens were stored.");
markOutcome("success", "Check your email to verify your account, then sign in.");
return;
}

Expand Down
13 changes: 9 additions & 4 deletions frontend/dev-pages/src/pages/powersync-sandbox/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ interface CurrentUser {
email?: string | null;
}

interface PendingVerificationPayload {
status: "verification_required";
email: string;
message: string;
resend_after: number;
}

interface AuthTokensPayload {
access_token?: string;
refresh_token?: string;
Expand Down Expand Up @@ -549,7 +556,7 @@ async function handleOAuthReturn(): Promise<void> {
function registerHandlers(): void {
refs.register.onclick = async () => {
await runButtonAction("register", refs.register, "Registering", "Registering user", async () => {
const { response, body } = await callSandboxApi<AuthTokensPayload>(config.register_url, {
const { response, body } = await callSandboxApi<PendingVerificationPayload>(config.register_url, {
method: "POST",
body: JSON.stringify({
email: refs.email.value,
Expand All @@ -561,9 +568,7 @@ function registerHandlers(): void {
});

if (response.ok && body && typeof body === "object") {
applyTokens(body as AuthTokensPayload);
await refreshServerItems();
markOutcome("success", "Registration succeeded.");
markOutcome("success", "Check your email to verify your account, then sign in.");
return;
}

Expand Down
26 changes: 23 additions & 3 deletions papyrus/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from papyrus.core.database import get_db
from papyrus.core.security import decode_token
from papyrus.models import AuthSession
from papyrus.models import AuthSession, User

security = HTTPBearer()

Expand All @@ -39,7 +39,7 @@ async def get_current_access_token_payload(
return payload


async def get_current_auth_session(
async def get_revocable_auth_session(
payload: Annotated[dict[str, Any], Depends(get_current_access_token_payload)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> AuthSession:
Expand All @@ -62,6 +62,13 @@ async def get_current_auth_session(
detail={"code": "INVALID_TOKEN", "message": "Token contains malformed identifiers"},
) from exc

await db.scalar(
select(User)
.where(User.user_id == user_uuid)
.with_for_update(key_share=True)
.execution_options(populate_existing=True),
)

result = await db.execute(
select(AuthSession).options(selectinload(AuthSession.user)).where(AuthSession.session_id == session_uuid)
)
Expand All @@ -80,7 +87,7 @@ async def get_current_auth_session(
detail={"code": "SESSION_REVOKED", "message": "Session is expired or revoked"},
)

if auth_session.user.disabled_at is not None:
if auth_session.user.disabled_at is not None or auth_session.user.deletion_due_at is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "ACCOUNT_DISABLED", "message": "User account is disabled"},
Expand All @@ -89,6 +96,19 @@ async def get_current_auth_session(
return auth_session


async def get_current_auth_session(
auth_session: Annotated[AuthSession, Depends(get_revocable_auth_session)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> AuthSession:
from papyrus.services.auth.verification import require_verified

await require_verified(db, auth_session.user)
return auth_session


RevocableAuthSession = Annotated[AuthSession, Depends(get_revocable_auth_session)]


async def get_current_user_id(
request: Request,
auth_session: Annotated[AuthSession, Depends(get_current_auth_session)],
Expand Down
Loading
Loading