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
82 changes: 82 additions & 0 deletions apps/backend/alembic/versions/0016_approved_emails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""add approved_emails, seed the product owner's address

Revision ID: 0016_approved_emails
Revises: 0015_oauth_identities
Create Date: 2026-08-29

Issue #374: replaces single-use invite codes (#341) with an admin-managed
approved-email allowlist as the registration gate. `invite_tokens` is left
in place as a historical audit record -- nothing drops it -- but nothing in
the live registration path consults it after this migration; only
``approved_emails`` does.

Seeds exactly one row: the product owner's own address, so this migration
can never lock him out of the very system it's gating. No other real
account email could be identified anywhere in this codebase to also seed --
the only pre-existing seed user (``users.id ==
'00000000-0000-0000-0000-000000000000'``) is an explicit non-login system
placeholder (``password_hash`` is null, and both the password-login and
OAuth-login paths already refuse to authenticate it), not a real owner
account, so it is deliberately not approved here.
"""

from datetime import UTC, datetime

import sqlalchemy as sa
from alembic import op

revision = "0016_approved_emails"
down_revision = "0015_oauth_identities"
branch_labels = None
depends_on = None

# Kept in one place so upgrade() and downgrade() can never disagree on which
# row this migration is responsible for.
_SEEDED_EMAIL = "parthrohit60@gmail.com"

approved_emails = sa.table(
"approved_emails",
sa.column("id", sa.String),
sa.column("email", sa.String),
sa.column("note", sa.String),
sa.column("added_by", sa.String),
sa.column("created_at", sa.DateTime),
)


def upgrade() -> None:
op.create_table(
"approved_emails",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("email", sa.String(length=320), nullable=False),
sa.Column("note", sa.String(length=255), nullable=True),
sa.Column("added_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"used_by_user_id",
sa.String(length=36),
sa.ForeignKey("users.id", ondelete="SET NULL", name="fk_approved_emails_used_by_user_id_users"),
nullable=True,
),
sa.UniqueConstraint("email", name="uq_approved_emails_email"),
)
op.create_index("ix_approved_emails_email", "approved_emails", ["email"], unique=True)

op.bulk_insert(
approved_emails,
[
{
"id": "00000000-0000-0000-0000-000000000001",
"email": _SEEDED_EMAIL,
"note": "Pre-approved: product owner, seeded by migration 0016 so this change can never lock him out.",
"added_by": "migration:0016_approved_emails",
"created_at": datetime.now(UTC),
}
],
)


def downgrade() -> None:
op.drop_index("ix_approved_emails_email", table_name="approved_emails")
op.drop_table("approved_emails")
3 changes: 1 addition & 2 deletions apps/backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"value": {
"email": "developer@example.com",
"password": "correct-horse-battery-staple",
"inviteCode": "example-invite-code",
},
}
_LOGIN_EXAMPLE = {
Expand Down Expand Up @@ -72,7 +71,7 @@ def register(
service: AuthService = Depends(get_auth_service),
settings: Settings = Depends(get_settings),
) -> AuthResponse:
user, access_token, raw_refresh = service.register(request.email, request.password, request.invite_code)
user, access_token, raw_refresh = service.register(request.email, request.password)
_set_refresh_cookie(response, raw_refresh, settings)
return AuthResponse(access_token=access_token, user=UserResponse.model_validate(user))

Expand Down
6 changes: 5 additions & 1 deletion apps/backend/app/auth/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,5 +84,9 @@ def hash_invite_code(raw: str) -> str:
code and a refresh token are different secrets with different lifetimes,
and a shared helper would blur that even though the hash itself is
identical (sha256 hex digest -- both are high-entropy random tokens, not
passwords, so argon2 would be the wrong tool here)."""
passwords, so argon2 would be the wrong tool here).

Retained only for historical continuity with the `invite_tokens` table
(#341, retired by #374's admin-managed email allowlist) -- nothing in
the live registration path calls this anymore."""
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
85 changes: 47 additions & 38 deletions apps/backend/app/auth/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@
from app.auth.security import (
burn_password_check,
create_access_token,
hash_invite_code,
hash_password,
hash_refresh_token,
new_refresh_token,
verify_password,
)
from app.core.config import Settings
from app.core.exceptions import ConflictServiceError, UnauthorizedError, ValidationServiceError
from app.models.invite_token import InviteToken
from app.models.approved_email import ApprovedEmail
from app.models.refresh_token import RefreshToken
from app.models.user import User

Expand All @@ -27,10 +26,10 @@
# used to tell apart unknown email / wrong password / disabled account.
INVALID_CREDENTIALS = "Invalid email or password."
INVALID_REFRESH = "Invalid refresh token."
# Deliberately the same message whether the code was never issued, was
# already redeemed, or lost the redemption race below -- distinguishing them
# would let a caller probe which invite codes exist.
INVALID_INVITE_CODE = "Invalid or already-used invite code."
# #374: registration is gated by an admin-managed allowlist, not a secret --
# unlike the retired invite-code message, this can say exactly what's wrong,
# the same way the waitlist's own "we'll be in touch" framing does.
EMAIL_NOT_APPROVED = "This email hasn't been approved for access yet. Join the waitlist and we'll be in touch."


def _as_utc(value: datetime) -> datetime:
Expand All @@ -44,50 +43,60 @@ def __init__(self, db: Session, settings: Settings) -> None:
self.db = db
self.settings = settings

def register(self, email: str, password: str, invite_code: str) -> tuple[User, str, str]:
def register(self, email: str, password: str) -> tuple[User, str, str]:
normalized = email.strip().lower()
existing = self.db.scalars(select(User).where(User.email == normalized)).first()
self._ensure_email_available(normalized)
approval = self._require_approval(normalized)
user = User(id=str(uuid4()), email=normalized, password_hash=hash_password(password))
return self._create_approved_user(user, approval)

def register_oauth_user(self, email: str) -> tuple[User, str, str]:
"""Create a brand-new, password-less account for a verified OAuth
identity (OAuthService, #288/#374).

Identical approval gate and audit trail as ``register()`` -- the
allowlist is the single source of truth for who may ever get a new
PARTHA account, regardless of which door (password or OAuth) they
come through. The only difference from ``register()`` is that there
is no password to hash.
"""
normalized = email.strip().lower()
self._ensure_email_available(normalized)
approval = self._require_approval(normalized)
user = User(id=str(uuid4()), email=normalized, password_hash=None)
return self._create_approved_user(user, approval)

def _ensure_email_available(self, normalized_email: str) -> None:
existing = self.db.scalars(select(User).where(User.email == normalized_email)).first()
if existing:
raise ConflictServiceError("An account with this email already exists.")

invite = self.db.scalars(
select(InviteToken).where(InviteToken.code_hash == hash_invite_code(invite_code))
).first()
if invite is None or invite.redeemed_at is not None:
raise ValidationServiceError(INVALID_INVITE_CODE)
def _require_approval(self, normalized_email: str) -> ApprovedEmail:
approval = self.db.scalars(select(ApprovedEmail).where(ApprovedEmail.email == normalized_email)).first()
if approval is None:
raise ValidationServiceError(EMAIL_NOT_APPROVED)
return approval

user = User(id=str(uuid4()), email=normalized, password_hash=hash_password(password))
def _create_approved_user(self, user: User, approval: ApprovedEmail) -> tuple[User, str, str]:
self.db.add(user)
try:
# Flushed here, ahead of commit, so the invite update below (which
# references user.id via a foreign key) never runs before the
# referenced row actually exists -- SQLite enforces foreign keys
# per-statement, not deferred to commit. This is also the point
# a concurrent registration for the same email can now surface as
# an IntegrityError (moved earlier than commit by this same
# flush), so it needs the identical handling below that commit
# already had.
# Flushed here, ahead of commit, so a concurrent registration for
# the same email surfaces here as an IntegrityError rather than
# only at commit -- the identical handling is needed at both
# points, since either can be where the race actually lands.
self.db.flush()
except IntegrityError:
self.db.rollback()
if self.db.scalars(select(User).where(User.email == normalized)).first() is not None:
if self.db.scalars(select(User).where(User.email == user.email)).first() is not None:
raise ConflictServiceError("An account with this email already exists.") from None
raise

# Atomic conditional redemption, not a read-then-write: two concurrent
# registrations can both pass the `redeemed_at is not None` check
# above for the same code between each other's read and write, the
# same race the email-uniqueness check above guards against with a
# database constraint. The WHERE clause here is that guard for
# invites -- only one concurrent UPDATE can match redeemed_at IS NULL.
redemption = self.db.execute(
update(InviteToken)
.where(InviteToken.id == invite.id, InviteToken.redeemed_at.is_(None))
.values(redeemed_at=datetime.now(UTC), redeemed_by_user_id=user.id)
)
if redemption.rowcount != 1:
self.db.rollback()
raise ValidationServiceError(INVALID_INVITE_CODE)
# Purely informational (see ApprovedEmail's docstring) -- unlike the
# retired invite-code redemption, this never gates anything and so
# needs no atomic conditional UPDATE: the email-uniqueness check
# above is what actually prevents two accounts for one email.
approval.used_at = datetime.now(UTC)
approval.used_by_user_id = user.id

try:
self.db.commit()
Expand All @@ -96,7 +105,7 @@ def register(self, email: str, password: str, invite_code: str) -> tuple[User, s
# collision itself is already handled above, at flush) must still
# not surface as a raw 500.
self.db.rollback()
if self.db.scalars(select(User).where(User.email == normalized)).first() is not None:
if self.db.scalars(select(User).where(User.email == user.email)).first() is not None:
raise ConflictServiceError("An account with this email already exists.") from None
raise
self.db.refresh(user)
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from app.models.ai_conversation import AiConversationMessageRecord
from app.models.ai_provider_config import AiProviderConfigRecord
from app.models.analysis_job import AnalysisJob
from app.models.approved_email import ApprovedEmail
from app.models.invite_token import InviteToken
from app.models.oauth_flow_state import OAuthFlowState
from app.models.oauth_identity import OAuthIdentity
Expand All @@ -27,6 +28,7 @@
"AiConversationMessageRecord",
"AiProviderConfigRecord",
"AnalysisJob",
"ApprovedEmail",
"InviteToken",
"OAuthFlowState",
"OAuthIdentity",
Expand Down
37 changes: 37 additions & 0 deletions apps/backend/app/models/approved_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from datetime import UTC, datetime

from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column

from app.models.base import Base


class ApprovedEmail(Base):
"""One admin-approved email address, the registration gate for the
invite-only beta (#374, superseding the single-use invite codes of
#341).

Unlike an invite code, an approved email is not a scarce secret and is
not consumed by use: it stays approved indefinitely, and re-registering
the same email a second time is already rejected by `User.email`'s own
uniqueness constraint regardless of this table's state. `used_at`/
`used_by_user_id` are purely informational -- mirroring the spirit of
`InviteToken`'s audit trail (who/when) -- not a gate.
"""

__tablename__ = "approved_emails"

id: Mapped[str] = mapped_column(String(36), primary_key=True)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
# Free-form operator note (e.g. which waitlist entry this was approved
# for) -- never displayed to the registrant.
note: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Free text, not a User FK: there is no admin-role concept in this app,
# and whoever runs scripts/approve_email.py is not necessarily a PARTHA
# account at all. Purely an operator-facing audit label.
added_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
used_by_user_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
1 change: 0 additions & 1 deletion apps/backend/app/schemas/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
class RegisterRequest(CamelModel):
email: EmailStr
password: str = Field(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH)
invite_code: str = Field(min_length=1, max_length=255)


class LoginRequest(CamelModel):
Expand Down
49 changes: 38 additions & 11 deletions apps/backend/app/services/oauth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ def _as_utc(value: datetime) -> datetime:


def _hash_state(raw: str) -> str:
# Same sha256-hex construction as refresh tokens/invite codes: `state` is
# a bearer secret (the CSRF protection), so only its hash is stored.
# Same sha256-hex construction as refresh tokens: `state` is a bearer
# secret (the CSRF protection), so only its hash is stored.
return hash_refresh_token(raw)


Expand Down Expand Up @@ -221,15 +221,42 @@ def _complete_login(self, provider: str, identity: OAuthIdentityInfo) -> OAuthLo
self.db.commit()
return OAuthLoginResult(kind="pending_link", pending_link_id=pending.id)

# No existing account to sign into or link -- and, deliberately, no
# brand-new account is created here either. Registration is
# invite-gated everywhere else in the product (AuthService.register
# requires a redeemed invite code); silently creating an account over
# OAuth with no invite check at all would be a real, unintended
# bypass of that gate, not a feature. Until there's a real decision
# on how an invite code fits into the OAuth flow, a new visitor is
# sent back to the invite-gated registration form instead.
return OAuthLoginResult(kind="error", error_code="signup_requires_invite")
# No existing account to sign into or link. A brand-new account is
# only ever created here if the verified provider email is itself on
# the same admin-managed allowlist that gates password registration
# (#374) -- AuthService.register_oauth_user() is the exact same
# approval-gate-and-audit-trail code path as AuthService.register(),
# just without a password. Anything else (unapproved, or no
# verified email at all) is refused; OAuth is never a second, looser
# door into the product than password registration is.
if not identity.email or not identity.email_verified:
return OAuthLoginResult(kind="error", error_code="email_not_approved")
try:
db_user, access_token, refresh_token = self.auth_service.register_oauth_user(identity.email)
except ValidationServiceError:
return OAuthLoginResult(kind="error", error_code="email_not_approved")
except ConflictServiceError:
# Lost a concurrent-registration race for this exact email
# (vanishingly rare, same class of race AuthService.register()
# itself guards against) -- nothing left to do but report it.
return OAuthLoginResult(kind="error", error_code="email_already_registered")

# The account now exists; record the identity that created it so a
# later sign-in with this same provider account reuses it instead of
# re-running the approval check (the "existing identity" branch at
# the top of this method).
self.db.add(
OAuthIdentity(
id=str(uuid4()),
user_id=db_user.id,
provider=provider,
provider_subject=identity.subject,
email=identity.email,
created_at=datetime.now(UTC),
)
)
self.db.commit()
return OAuthLoginResult(kind="session", user=db_user, access_token=access_token, refresh_token=refresh_token)

def _complete_link(self, provider: str, identity: OAuthIdentityInfo, link_user_id: str | None) -> OAuthLoginResult:
if not link_user_id:
Expand Down
Loading
Loading