Harden auth, invites and deployment after security review

- Signed-out visitors get the same login redirect for missing and
  private projects, so the redirect no longer reveals private ones.
- Production refuses the console email backend; sign-in codes must
  never reach the logs.
- Database cache shared by all workers for allauth rate limits; invite
  sending throttled per sender and per recipient.
- Email invite links are claimed on first use and the token rotated,
  so a token copied from request logs is worthless.
- allauth password URLs removed; adding an email requires a fresh code
  and notifies existing addresses.
- Markdown drops remote images until uploads exist.
- Project and org slugs lowercased; only superusers can grant staff
  status or permissions in the admin.
- Container runs as a non-root user; CI actions pinned to commits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Alec K2XAP 2026-10-27 08:42:19 -04:00
parent b9378b8397
commit 99f810d043
25 changed files with 281 additions and 42 deletions

View file

@ -9,8 +9,9 @@ jobs:
# Adjust to match the labels of your Forgejo runner.
runs-on: docker
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- uses: https://github.com/astral-sh/setup-uv@v6
# Actions are pinned to commits so a moved tag can't change what runs here.
- uses: https://data.forgejo.org/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: https://github.com/astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
with:
version: "0.13.6"
- run: uv sync --locked
@ -23,8 +24,8 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: docker
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- uses: https://github.com/superfly/flyctl-actions/setup-flyctl@master
- uses: https://data.forgejo.org/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: https://github.com/superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # master, 2026-10
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

View file

@ -23,13 +23,17 @@ CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py runserv
FROM base AS prod
COPY --from=litestream/litestream:0.5.17 /usr/local/bin/litestream /usr/local/bin/litestream
# The app runs as this user; bin/start drops to it after making the data volume writable.
RUN useradd --system --uid 1000 --create-home app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY litestream.yml /etc/litestream.yml
COPY . .
# Placeholder settings: collectstatic needs the prod settings to import, nothing more.
RUN DJANGO_SETTINGS_MODULE=config.settings.prod DJANGO_SECRET_KEY=collectstatic \
EMAIL_BACKEND=django.core.mail.backends.dummy.EmailBackend \
python manage.py collectstatic --noinput
ENV DJANGO_SETTINGS_MODULE=config.settings.prod
EXPOSE 8000

View file

@ -3,16 +3,24 @@
# because release machines don't mount the volume that holds the SQLite database.
set -eu
# The volume is mounted owned by root. Take ownership for the app user, then rerun as it.
if [ "$(id -u)" = 0 ]; then
chown -R app:app "${INTENT_DATA_DIR:?}"
exec setpriv --reuid=app --regid=app --init-groups env HOME=/home/app "$0" "$@"
fi
DB="${INTENT_DATA_DIR:?}/db.sqlite3"
SERVER="gunicorn config.wsgi --bind 0.0.0.0:8000 --workers 3 --access-logfile -"
if [ -z "${LITESTREAM_BUCKET:-}" ]; then
echo "LITESTREAM_BUCKET not set: running without backups" >&2
python manage.py migrate --noinput
python manage.py createcachetable
exec $SERVER
fi
# On a fresh volume, pull the latest backup before anything touches the database.
litestream restore -if-db-not-exists -if-replica-exists "$DB"
python manage.py migrate --noinput
python manage.py createcachetable
exec litestream replicate -exec "$SERVER"

View file

@ -76,6 +76,15 @@ DATABASES = {
}
}
# Rate limits (allauth's and ours) live here, so the cache must be shared by every worker
# and survive restarts. The database is the only shared store on a one-machine deploy.
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
"LOCATION": "cache",
}
}
AUTH_USER_MODEL = "accounts.User"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
@ -100,6 +109,10 @@ ACCOUNT_LOGIN_BY_CODE_ENABLED = True
ACCOUNT_LOGIN_BY_CODE_SUPPORTS_RESEND = True
ACCOUNT_LOGIN_BY_CODE_TIMEOUT = 600
ACCOUNT_LOGOUT_ON_GET = False
# Adding an email address adds a way to sign in, so ask for a fresh code first, and tell
# the existing addresses about it.
ACCOUNT_REAUTHENTICATION_REQUIRED = True
ACCOUNT_EMAIL_NOTIFICATIONS = True
SOCIALACCOUNT_PROVIDERS = {
"github": {"SCOPE": ["read:user", "user:email"]},
}

View file

@ -5,3 +5,5 @@ SECRET_KEY = SECRET_KEY or "dev-insecure-secret-key" # noqa: F405
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"]
DATA_DIR.mkdir(exist_ok=True) # noqa: F405
# Avoid needing `createcachetable` for local development.
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}

View file

@ -7,6 +7,9 @@ from .base import * # noqa: F403
if not SECRET_KEY: # noqa: F405
raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set")
# The console backend would print sign-in codes and invite links into the logs.
if EMAIL_BACKEND == "django.core.mail.backends.console.EmailBackend": # noqa: F405
raise ImproperlyConfigured("EMAIL_BACKEND must be set to a real email backend in production")
ALLOWED_HOSTS = [h for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h]
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]

View file

@ -2,6 +2,7 @@ from .base import * # noqa: F403
SECRET_KEY = "test-secret-key"
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
STORAGES = {
**STORAGES, # noqa: F405
"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"},

View file

@ -1,14 +1,31 @@
import allauth.urls
from allauth.account import urls as allauth_account_urls
from django.contrib import admin
from django.urls import include, path
from django.urls import URLResolver, include, path
from intent.core import views
from intent.orgs.urls import invite_urlpatterns
from intent.projects.views import project_create
def _without_passwords(patterns):
"""allauth's URLs minus the password views: regular users sign in with a code only."""
return [p for p in patterns if not str(p.pattern).startswith("password/")]
account_urlpatterns = [
(
path("", include(_without_passwords(allauth_account_urls.urlpatterns)))
if isinstance(p, URLResolver) and p.urlconf_name is allauth_account_urls
else p
)
for p in allauth.urls.urlpatterns
]
urlpatterns = [
path("", views.home, name="home"),
path("admin/", admin.site.urls),
path("accounts/", include("allauth.urls")),
path("accounts/", include(account_urlpatterns)),
path("invites/", include(invite_urlpatterns)),
# Organizations own the rest of the URL space (see RESERVED_SLUGS in orgs and projects).
path("<slug:org_slug>/new/", project_create, name="project_create"),

View file

@ -34,10 +34,10 @@ Decisions made in the concept are not repeated. These fill the gaps.
| Thread → issue | Original commenter or any team member. Thread gets `promoted_to` issue; issue body is prefilled with the thread text and links back. |
| Deleted users | Content remains, attributed to a "ghost" user. |
| Moderation | Project owners can delete comments and close issues in their project. Sysadmin can deactivate a user site-wide. |
| Invites | By email or existing username. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and whoever holds it may accept with any account. Email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Expire after 14 days. The last org owner cannot be removed, demoted or leave. |
| Invites | By email or existing username. Username invites appear on the invitee's Invitations page (no notifications in V1). Email invites send a link carrying a random token (only its hash is stored); the link requires sign-in and the first signed-in visitor claims the invite with whatever account they used. Claiming rotates the token, so the link (which appears in request logs) is dead after one use; the claimant then accepts or declines from their Invitations page. Unclaimed email invites also appear on the Invitations page of a user with that verified email. Re-inviting replaces the pending invite. Sending is throttled (30 per sender per hour, 3 per recipient per org per hour). Expire after 14 days. The last org owner cannot be removed, demoted or leave. |
| URLs | Organizations live at the root: `/<org>/`, `/<org>/<project>/`, `/<org>/<project>/features/INT-12/`. App routes are reserved slugs at both levels. |
| Project slug and prefix | Fixed after creation, since they appear in URLs and feature IDs. Name, visibility and open contribution stay editable. |
| Hidden projects | A project the user can't see returns 404 (not 403), so private projects don't reveal they exist. Signed-out visitors are sent to sign in instead. |
| Hidden projects | A project the user can't see returns 404 (not 403), so private projects don't reveal they exist. Signed-out visitors are sent to sign in instead, for missing and hidden projects alike, so the redirect reveals nothing either. Markdown drops remote images (`<img>`) until uploads exist, so an author can't log readers' IP addresses. |
| Project owners | No last-owner guard: org owners can always administer every project. Leaving the org removes a user's project roles in it. |
| Feature editing | One form edits title, description, parent, status and replacement, with a required change note. Saving with no changes is refused. A "Preview description" button renders the Markdown server-side, so preview works without JS. Django admin shows features read-only so every change goes through revisions. |
@ -46,8 +46,9 @@ Decisions made in the concept are not repeated. These fill the gaps.
| Topic | Default |
|---|---|
| Runtime | Python 3.13, Django 5.2 LTS, `uv` for dependencies, `ruff` for lint/format. |
| Auth | `django-allauth`: GitHub, Discord, OpenID Connect provider per Forgejo instance (Forgejo supports OIDC). Sysadmins register each provider as a SocialApp in Django admin, so adding a Forgejo instance needs no deploy. Email sign-in is allauth's login-by-code: the "magic link" is an emailed one-time code, not a clickable link. Signup asks for email and username only; regular users never get a password. Multiple social accounts per user via allauth's connect flow (no automatic linking by email). Usernames: letters, digits, `-`, `_`, max 39. |
| Sysadmins | Django `is_staff` users with passwords, Django admin at `/admin/`. A model constraint rejects staff users as org members. Regular users have no password (unusable). |
| Auth | `django-allauth`: GitHub, Discord, OpenID Connect provider per Forgejo instance (Forgejo supports OIDC). Sysadmins register each provider as a SocialApp in Django admin, so adding a Forgejo instance needs no deploy. Email sign-in is allauth's login-by-code: the "magic link" is an emailed one-time code, not a clickable link. Signup asks for email and username only; regular users never get a password, and allauth's password URLs are not routed at all. Adding an email address requires re-entering a fresh code, and existing addresses are notified. Multiple social accounts per user via allauth's connect flow (no automatic linking by email). Usernames: letters, digits, `-`, `_`, max 39. |
| Sysadmins | Django `is_staff` users with passwords, Django admin at `/admin/`. A model constraint rejects staff users as org members. Regular users have no password (unusable). Only superusers can grant staff or superuser status or permissions in the admin. |
| Cache | Django's database cache (`createcachetable` runs at start). It holds allauth's rate limits and the invite throttle, so it must be shared by all gunicorn workers and survive restarts; the database is the only shared store on a one-machine deploy. Dev and tests use the in-memory cache. |
| Database | SQLite on a Fly volume. `transaction_mode = "IMMEDIATE"`, WAL mode, `busy_timeout` 5 s. Litestream replicates to B2 continuously. |
| Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed `<iframe>` whose `src` is a signed URL on the Tigris origin, never rendered inline on the app origin. |
| Markdown | `markdown-it-py` with source line mapping (`data-line` attributes on blocks) for line threads; output sanitized with `nh3`. Diffs via `difflib` rendered server-side. |
@ -185,7 +186,7 @@ Each phase ends deployable. Rough sizes assume one person, part-time; adjust fre
- Pick email provider, configure anymail, verify magic link and invite emails.
- Moderation: deactivate user (sysadmin), delete comments (project owner), ghost user on account deletion.
- Playwright smoke tests with axe checks on the main pages in both color schemes.
- Rate limiting on login and comment endpoints (`django-ratelimit`), CSP header, security headers review.
- Rate limiting on comment endpoints (allauth already limits its own views; invites are throttled), CSP header, security headers review, second factor for `/admin/`.
- Backup restore drill repeated against the real volume.
- **Exit:** launch checklist (below) complete.
@ -199,6 +200,8 @@ Each phase ends deployable. Rough sizes assume one person, part-time; adjust fre
- [ ] axe passes on feature, issue, project and org pages, light and dark
- [ ] Keyboard-only walkthrough of the example flow
- [ ] Privacy check: private project content not reachable via file URLs, feeds, or admin leaks
- [ ] `EMAIL_BACKEND` set in production (prod settings refuse the console backend)
- [ ] Container confirmed running as the `app` user on the Fly volume
## 7. Hooks for V1.1

View file

@ -7,3 +7,10 @@ from .models import User
@admin.register(User)
class IntentUserAdmin(UserAdmin):
search_fields = ["username", "email"]
def get_readonly_fields(self, request, obj=None):
# Only superusers may hand out staff or superuser status, or permissions.
fields = list(super().get_readonly_fields(request, obj))
if not request.user.is_superuser:
fields += ["is_staff", "is_superuser", "groups", "user_permissions"]
return fields

View file

@ -11,6 +11,9 @@ from markdown_it import MarkdownIt
_ALLOWED_ATTRIBUTES = {tag: set(attrs) for tag, attrs in nh3.ALLOWED_ATTRIBUTES.items()}
_ALLOWED_ATTRIBUTES["*"] = {"data-source-lines"}
# No remote images: they would let an author log every reader's IP address.
# Uploaded images arrive in Phase 4 and will be served from our own storage.
_ALLOWED_TAGS = nh3.ALLOWED_TAGS - {"img"}
def _annotate(state):
@ -33,4 +36,4 @@ _md = _build()
def render(text: str) -> SafeString:
html = _md.render(text or "")
return mark_safe(nh3.clean(html, attributes=_ALLOWED_ATTRIBUTES))
return mark_safe(nh3.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRIBUTES))

View file

@ -15,6 +15,9 @@ class OrganizationAddForm(forms.ModelForm):
model = Organization
fields = ["name", "slug", "owner"]
def clean_slug(self):
return self.cleaned_data["slug"].lower()
class OrgMembershipInline(admin.TabularInline):
model = OrgMembership

View file

@ -0,0 +1,21 @@
# Generated by Django 5.2.18 on 2026-10-27 12:34
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orgs', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='orginvite',
name='claimed_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
]

View file

@ -105,6 +105,15 @@ class OrgInvite(BaseModel):
)
expires_at = models.DateTimeField(default=_expiry)
responded_at = models.DateTimeField(null=True, blank=True)
# Email invites: the first signed-in user to open the link claims the invite. The link
# stops working at that point, so a token copied from a log or a referrer is useless.
claimed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
# Who actually accepted. For email invites, whoever held the link.
accepted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,

View file

@ -5,6 +5,7 @@ Missing permissions raise PermissionDenied.
"""
from allauth.account.models import EmailAddress
from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.db.models import Q
@ -15,6 +16,12 @@ from intent.access import org_access
from .models import InviteStatus, Organization, OrgInvite, OrgMembership, Role
LAST_OWNER_MESSAGE = "An organization needs at least one owner. Make someone else an owner first."
TOO_MANY_INVITES_MESSAGE = "Too many invitations sent recently. Try again later."
# Each invite can send an email, so cap how many one person sends and how often one
# address is hit, with room for genuine use.
INVITES_PER_SENDER = (30, 60 * 60)
INVITES_PER_RECIPIENT = (3, 60 * 60)
def _require_owner(by, org):
@ -22,6 +29,20 @@ def _require_owner(by, org):
raise PermissionDenied
def _throttle(key: str, limit: int, seconds: int) -> None:
"""Count an event under `key`; raise once more than `limit` happen within `seconds`."""
key = f"intent:throttle:{key}"
if cache.add(key, 1, timeout=seconds):
return
try:
count = cache.incr(key)
except ValueError: # Expired between add and incr.
cache.add(key, 1, timeout=seconds)
return
if count > limit:
raise ValidationError(TOO_MANY_INVITES_MESSAGE)
def _is_last_owner(membership) -> bool:
return (
membership.role == Role.OWNER
@ -42,7 +63,7 @@ def create_org(*, slug, name, owner) -> Organization:
if owner.is_staff:
raise ValidationError("Administrator accounts cannot own organizations.")
with transaction.atomic():
org = Organization.objects.create(slug=slug, name=name)
org = Organization.objects.create(slug=slug.lower(), name=name)
OrgMembership.objects.create(org=org, user=owner, role=Role.OWNER)
return org
@ -56,6 +77,8 @@ def invite(org, by, *, user=None, email="", role=Role.COLLABORATOR) -> tuple[Org
email = email.strip().lower()
if bool(user) == bool(email):
raise ValueError("Invite exactly one of user or email")
_throttle(f"invites-by:{by.pk}", *INVITES_PER_SENDER)
_throttle(f"invites-to:{org.pk}:{user.pk if user else email}", *INVITES_PER_RECIPIENT)
with transaction.atomic():
if user:
@ -79,10 +102,14 @@ def invite(org, by, *, user=None, email="", role=Role.COLLABORATOR) -> tuple[Org
def open_invites_for(user):
"""Pending, unexpired invites addressed to this user by username or verified email."""
"""Pending, unexpired invites addressed to this user: by username, verified email, or link."""
return (
OrgInvite.objects.filter(status=InviteStatus.PENDING, expires_at__gt=timezone.now())
.filter(Q(user=user) | Q(email__in=_verified_emails(user), email__gt=""))
.filter(
Q(user=user)
| Q(claimed_by=user)
| Q(email__in=_verified_emails(user), email__gt="", claimed_by__isnull=True)
)
.select_related("org", "invited_by")
)
@ -90,9 +117,31 @@ def open_invites_for(user):
def is_addressed_to(invite, user) -> bool:
if invite.user_id:
return invite.user_id == user.pk
if invite.claimed_by_id:
return invite.claimed_by_id == user.pk
return invite.email in _verified_emails(user)
def claim_invite(invite, user) -> None:
"""Bind an email invite to the signed-in user who opened its link, and retire the link.
Whoever holds the link may claim, whichever account they sign in with. Only one account
can: a second holder of the same link sees nothing.
"""
with transaction.atomic():
invite.refresh_from_db()
if invite.claimed_by_id and invite.claimed_by_id != user.pk:
raise PermissionDenied
if not invite.is_open:
raise ValidationError("This invitation is no longer valid.")
if user.is_staff:
raise ValidationError("Administrator accounts cannot join organizations.")
if invite.claimed_by_id is None:
invite.claimed_by = user
invite.set_new_token() # The old link stops working; the new token is discarded.
invite.save(update_fields=["claimed_by", "token_hash", "updated_at"])
def accept_invite(invite, user) -> OrgMembership:
with transaction.atomic():
invite.refresh_from_db()

View file

@ -15,6 +15,7 @@ urlpatterns = [
invite_urlpatterns = (
[
path("", views.my_invites, name="list"),
path("<str:invite_id>/", views.invite_show, name="show"),
path("<str:invite_id>/respond/", views.invite_respond, name="respond"),
path("t/<str:token>/", views.invite_by_token, name="by_token"),
],

View file

@ -2,6 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.mail import send_mail
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
@ -145,21 +146,37 @@ def _respond(request, invite):
return redirect("invites:list")
def _addressed_invite(request, invite_id):
invite = get_object_or_404(OrgInvite.objects.select_related("org", "invited_by"), pk=invite_id)
if not services.is_addressed_to(invite, request.user):
raise PermissionDenied
return invite
@login_required
def invite_show(request, invite_id):
return render(request, "orgs/invite.html", {"invite": _addressed_invite(request, invite_id)})
@login_required
@require_POST
def invite_respond(request, invite_id):
invite = get_object_or_404(OrgInvite.objects.select_related("org"), pk=invite_id)
if not services.is_addressed_to(invite, request.user):
raise PermissionDenied
return _respond(request, invite)
return _respond(request, _addressed_invite(request, invite_id))
@login_required
def invite_by_token(request, token):
"""Email invites: whoever holds the link may accept, whichever account they sign in with."""
invite = get_object_or_404(
OrgInvite.objects.select_related("org", "invited_by"), token_hash=hash_token(token)
)
if request.method == "POST":
return _respond(request, invite)
return render(request, "orgs/invite.html", {"invite": invite})
"""Email invite links. The first signed-in visitor claims the invite and the link dies.
Nothing is shown at this URL: the token appears in request logs, so it must be worthless
the moment it has been used once.
"""
invite = get_object_or_404(OrgInvite, token_hash=hash_token(token))
try:
services.claim_invite(invite, request.user)
except PermissionDenied:
raise Http404 from None
except ValidationError as exc:
_error(request, exc)
return redirect("invites:list")
return redirect("invites:show", invite_id=invite.pk)

View file

@ -3,7 +3,6 @@ from functools import wraps
from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.shortcuts import get_object_or_404
from intent.access import project_access
@ -14,20 +13,23 @@ def project_view(require="can_view"):
"""Load the project from the URL, check access, and call view(request, project, access, ...).
Projects a user can't see are 404s, so private projects don't reveal they exist.
Signed-out visitors are sent to sign in instead.
Signed-out visitors are sent to sign in instead, whether or not the project exists,
so the redirect doesn't reveal it either.
"""
def decorator(view):
@wraps(view)
def wrapper(request, org_slug, project_slug, **kwargs):
project = get_object_or_404(
Project.objects.select_related("org"), org__slug=org_slug, slug=project_slug
project = (
Project.objects.select_related("org")
.filter(org__slug=org_slug, slug=project_slug)
.first()
)
access = project_access(request.user, project)
if not access.can_view or not getattr(access, require):
access = project_access(request.user, project) if project else None
if access is None or not access.can_view or not getattr(access, require):
if not request.user.is_authenticated:
return redirect_to_login(request.get_full_path())
if not access.can_view:
if access is None or not access.can_view:
raise Http404
raise PermissionDenied
return view(request, project, access, **kwargs)

View file

@ -21,6 +21,9 @@ class ProjectCreateForm(forms.ModelForm):
}
widgets = {"visibility": forms.RadioSelect}
def clean_slug(self):
return self.cleaned_data["slug"].lower()
def clean_feature_prefix(self):
return self.cleaned_data["feature_prefix"].upper()

View file

@ -19,7 +19,8 @@ def create_project(org, by, *, slug, name, feature_prefix, visibility, open_cont
raise PermissionDenied
project = Project(
org=org,
slug=slug,
# Slugs are lowercased so two projects can't differ only by case in the URL.
slug=slug.lower(),
name=name,
feature_prefix=feature_prefix.upper(),
visibility=visibility,

View file

@ -9,7 +9,7 @@
{% if invite.invited_by %}{{ invite.invited_by.username }} invited you{% else %}You're invited{% endif %}
to join as {{ invite.get_role_display|lower }}. You'll join as <strong>{{ user.username }}</strong>.
</p>
<form method="post">
<form method="post" action="{% url 'invites:respond' invite_id=invite.pk %}">
{% csrf_token %}
<button type="submit" name="action" value="accept">Accept</button>
<button type="submit" name="action" value="decline">Decline</button>

View file

@ -3,7 +3,7 @@ import re
import pytest
from django.core import mail
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.urls import NoReverseMatch, reverse
from intent.accounts.validators import username_validators
@ -50,3 +50,28 @@ def test_invalid_usernames(value):
with pytest.raises(ValidationError):
for validator in username_validators:
validator(value)
@pytest.mark.parametrize(
"name", ["account_set_password", "account_change_password", "account_reset_password"]
)
def test_password_urls_do_not_exist(name):
with pytest.raises(NoReverseMatch):
reverse(name)
def test_signed_in_pages_without_passwords_render(client, owner):
client.force_login(owner)
assert client.get(reverse("account_email")).status_code == 200
assert client.get("/accounts/password/set/").status_code == 404
def test_staff_cannot_grant_staff_without_superuser(client, make_user):
from django.contrib.auth.models import Permission
staff = make_user("stan", is_staff=True)
staff.user_permissions.add(*Permission.objects.filter(content_type__app_label="accounts"))
client.force_login(staff)
target = make_user("tina")
page = client.get(reverse("admin:accounts_user_change", args=[target.pk])).content.decode()
assert 'name="is_superuser"' not in page and 'name="is_staff"' not in page

View file

@ -21,6 +21,10 @@ def test_raw_html_and_script_links_are_neutralised():
assert "onerror" not in html.replace("onerror=y&gt;", "")
def test_remote_images_are_dropped():
assert "<img" not in render("![pixel](https://tracker.example/p.gif)")
def test_tables_and_strikethrough():
html = render("| a |\n|---|\n| 1 |\n\n~~old~~")
assert "<table" in html and "<s>old</s>" in html

View file

@ -1,6 +1,8 @@
import re
import pytest
from django.core import mail
from django.core.exceptions import ValidationError
from django.urls import reverse
from intent.orgs.models import InviteStatus, OrgInvite, Role
@ -68,13 +70,40 @@ def test_email_invite_link_flow(client, owner, make_user, org):
assert response.status_code == 302
assert response["Location"].startswith(reverse("account_login"))
# Bearer link: any signed-in account can accept, whatever its email.
# Bearer link: any signed-in account can claim it, whatever its email.
joiner = make_user("june", email="june@elsewhere.example")
client.force_login(joiner)
assert client.get(path).status_code == 200
client.post(path, {"action": "accept"})
invite = OrgInvite.objects.get(email="new@example.com")
show = reverse("invites:show", kwargs={"invite_id": invite.pk})
response = client.get(path)
assert response.status_code == 302 and response["Location"] == show
assert client.get(show).status_code == 200
assert invite in list(client.get(reverse("invites:list")).context["invites"])
# Once claimed, the link is dead for everyone else, even someone with the invited email.
other = make_user("newbie", email="new@example.com")
client.force_login(other)
assert client.get(path).status_code == 404
assert client.get(show).status_code == 403
assert client.get(reverse("invites:list")).context["invites"].count() == 0
client.force_login(joiner)
assert client.get(path).status_code == 404 # Token was rotated on claim.
client.post(reverse("invites:respond", kwargs={"invite_id": invite.pk}), {"action": "accept"})
assert org.memberships.filter(user=joiner).exists()
assert OrgInvite.objects.get(email="new@example.com").status == InviteStatus.ACCEPTED
invite.refresh_from_db()
assert invite.status == InviteStatus.ACCEPTED and invite.accepted_by == joiner
def test_invite_sending_is_throttled(owner, org):
from intent.orgs import services
for _ in range(3):
services.invite(org, owner, email="same@example.com")
with pytest.raises(ValidationError, match="Too many"):
services.invite(org, owner, email="same@example.com")
# A different address still works; the per-sender cap is higher.
services.invite(org, owner, email="other@example.com")
def test_invalid_token_is_404(client, make_user):

View file

@ -67,10 +67,14 @@ def test_reserved_slug_rejected(owner, org):
def test_private_project_hidden_from_outsiders(client, make_user, project):
response = client.get(url("home", project))
assert response.status_code == 302 and "/accounts/login/" in response["Location"]
missing = f"/{project.org.slug}/no-such-project/"
# Signed-out visitors can't tell a private project from a missing one: both ask to sign in.
for path in [url("home", project), missing]:
response = client.get(path)
assert response.status_code == 302 and "/accounts/login/" in response["Location"], path
client.force_login(make_user())
assert client.get(url("home", project)).status_code == 404
assert client.get(missing).status_code == 404
def test_public_project_visible_to_anonymous(client, project):
@ -125,3 +129,12 @@ def test_org_page_lists_visible_projects(client, owner, collaborator, org, proje
assert link in client.get(org_page).content.decode()
client.force_login(collaborator)
assert link not in client.get(org_page).content.decode()
def test_slugs_are_lowercased(client, owner, org):
client.force_login(owner)
client.post(
reverse("project_create", kwargs={"org_slug": org.slug}),
{"name": "Docs", "slug": "Docs", "feature_prefix": "doc", "visibility": "public"},
)
assert Project.objects.filter(slug="docs").exists()