Stage 7 · Master
Phase 12 — Visitor Service
Visitor Registration
Hash each invitation token, prove the host still belongs to the flat, and rate-limit a compromised resident before the gate becomes a self-issued entry system.
Why the QR token is treated like a bearer credential
A resident is not booking a calendar slot here; they are minting a credential that lets someone pass a physical security boundary. That changes the storage rule immediately: the raw token must never be stored in PostgreSQL. If the visitor database leaks and the service stored plaintext tokens, an attacker would not need to crack anything — they could walk to the gate with copied QR codes and redeem them. Module 12 starts by treating invitation tokens the same way a mature system treats other bearer secrets: generate them with high entropy, hand the raw value to the host once, and persist only a non-reversible hash.
Because the token is machine-generated and long, a fast cryptographic hash such as SHA-256 is enough here in a way it would not be for human passwords. The security property comes from random 256-bit entropy, not from a human-memorable secret. The important invariant is simple: the gate can compare hashes, but a database dump cannot reconstruct a usable visitor code.
The raw invitation token is equivalent to a temporary access badge. Store only token_hash, never the presented token itself, and do not log the raw token in application logs, traces, or metrics labels.
How the service proves the host belongs to the flat right now
The host eligibility check is not a UI convenience; it is the difference between a resident inviting their own guest and a curious user trying random flat IDs. The repository first checks that the requested flat exists inside the caller's org_id. If that lookup misses, the handler returns 404, not 403, because cross-tenant flat existence is not information we disclose. Only after that tenant-scoped existence check passes does the service join active occupancy data to resident records and confirm the requesting user is still an ACTIVE owner or occupant of that same flat.
That ordering closes an easy enumeration bug. A hostile resident from one society must not be able to learn whether flat UUIDs from another society exist by observing different error codes. The tenant-isolation posture from earlier modules remains intact here: every repository query starts with org_id from the JWT, and anything outside that scope behaves as if it never existed.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE visitor_invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
flat_id UUID NOT NULL,
host_user_id UUID NOT NULL,
visitor_name TEXT NOT NULL,
purpose TEXT NOT NULL,
valid_from TIMESTAMPTZ NOT NULL,
valid_until TIMESTAMPTZ NOT NULL,
token_hash TEXT NOT NULL,
used BOOLEAN NOT NULL DEFAULT FALSE,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT visitor_invitations_window_chk CHECK (valid_until > valid_from),
CONSTRAINT visitor_invitations_name_chk CHECK (length(trim(visitor_name)) > 1),
CONSTRAINT visitor_invitations_purpose_chk CHECK (length(trim(purpose)) > 1),
CONSTRAINT visitor_invitations_token_hash_key UNIQUE (org_id, token_hash)
);
CREATE INDEX visitor_invitations_org_flat_created_idx
ON visitor_invitations (org_id, flat_id, created_at DESC);
CREATE INDEX visitor_invitations_pending_idx
ON visitor_invitations (org_id, flat_id, valid_until)
WHERE used = FALSE;The table keeps enough information to validate eligibility, enforce windows, and later write visit history, but it never stores a reusable raw token.
visitor-service has its own database, and flats live in flat-service's. A REFERENCES clause across that boundary is not merely discouraged here; it is unwritable, because the two schemas are never in the same PostgreSQL instance in production. The consequence is concrete: nothing in the database stops an invitation being created for a flat UUID that does not exist, or that belongs to a different organization. The host-eligibility check earlier in this lesson is therefore not a convenience — it is the only enforcement there is, and the order in which it runs is what closes the enumeration hole described above.
DROP TABLE IF EXISTS visitor_invitations;Visitor lands as an additive table, so the down migration is intentionally small and reversible.
Where the daily quota becomes an enforced invariant
The quota does not live in React state or in the guard's memory; it lives in the application service and repository query that counts today's still-pending invitations for the flat. That specific placement matters in the compromised-account scenario. If an attacker steals one resident account and scripts invitation creation, requests one through ten can succeed, but request eleven must fail on the server even if the attacker sends it from another browser, another IP, or another device entirely.
Notice the scope: the quota is per flat, per day, inside one org. That keeps a penthouse in Tower A from exhausting invitation capacity for Tower B, and it keeps one noisy resident from filling a whole society's pending-guest queue.
package visitor
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const defaultDailyInvitationQuota = 10
type Invitation struct {
ID uuid.UUID
OrgID uuid.UUID
FlatID uuid.UUID
HostUserID uuid.UUID
VisitorName string
Purpose string
ValidFrom time.Time
ValidUntil time.Time
TokenHash string
Used bool
UsedAt *time.Time
CreatedAt time.Time
}
type CreateInvitationInput struct {
FlatID uuid.UUID
VisitorName string
Purpose string
ValidFrom time.Time
ValidUntil time.Time
}
type IssuedInvitation struct {
Invitation Invitation
RawToken string
}
var (
ErrInvitationWindowInvalid = errors.New("visitor: invalid invitation window")
ErrFlatNotFound = errors.New("visitor: flat not found")
ErrHostNotEligible = errors.New("visitor: host is not an active occupant or owner")
ErrInvitationQuotaExceeded = errors.New("visitor: daily invitation quota exceeded")
)
type TokenGenerator interface {
New() (string, error)
}
// InvitationStore covers only tables visitor-service owns.
type InvitationStore interface {
PendingInvitationCount(ctx context.Context, orgID, flatID uuid.UUID) (int, error)
InsertInvitation(ctx context.Context, params InsertInvitationParams) (Invitation, error)
}
// FlatClient and ResidentClient are the two facts this service needs but does
// not own. Both are declared here, beside the code that consumes them, and are
// satisfied by HTTP clients against flat-service and resident-service.
type FlatClient interface {
ExistsInOrg(ctx context.Context, orgID, flatID uuid.UUID) (bool, error)
}
type ResidentClient interface {
// IsActiveOccupant answers whether hostUserID currently owns or occupies
// flatID. The occupancy window and the OWNER/OCCUPANT distinction are
// resident-service's rules to apply, not this service's to reimplement.
IsActiveOccupant(ctx context.Context, orgID, flatID, hostUserID uuid.UUID) (bool, error)
}
type InsertInvitationParams struct {
OrgID uuid.UUID
FlatID uuid.UUID
HostUserID uuid.UUID
VisitorName string
Purpose string
ValidFrom time.Time
ValidUntil time.Time
TokenHash string
}
type InvitationService struct {
store InvitationStore
tokenGenerator TokenGenerator
flats FlatClient
residents ResidentClient
dailyInvitationQuota int
}
func NewInvitationService(store InvitationStore, flats FlatClient, residents ResidentClient, tokenGenerator TokenGenerator, dailyInvitationQuota int) *InvitationService {
if dailyInvitationQuota <= 0 {
dailyInvitationQuota = defaultDailyInvitationQuota
}
if tokenGenerator == nil {
tokenGenerator = randomTokenGenerator{}
}
return &InvitationService{
store: store,
flats: flats,
residents: residents,
tokenGenerator: tokenGenerator,
dailyInvitationQuota: dailyInvitationQuota,
}
}
func (s *InvitationService) CreateInvitation(ctx context.Context, orgID, hostUserID uuid.UUID, input CreateInvitationInput) (IssuedInvitation, error) {
if !input.ValidUntil.After(input.ValidFrom) {
return IssuedInvitation{}, ErrInvitationWindowInvalid
}
exists, err := s.flats.ExistsInOrg(ctx, orgID, input.FlatID)
if err != nil {
return IssuedInvitation{}, err
}
if !exists {
return IssuedInvitation{}, ErrFlatNotFound
}
eligible, err := s.residents.IsActiveOccupant(ctx, orgID, input.FlatID, hostUserID)
if err != nil {
return IssuedInvitation{}, err
}
if !eligible {
return IssuedInvitation{}, ErrHostNotEligible
}
pendingCount, err := s.store.PendingInvitationCount(ctx, orgID, input.FlatID)
if err != nil {
return IssuedInvitation{}, err
}
if pendingCount >= s.dailyInvitationQuota {
return IssuedInvitation{}, ErrInvitationQuotaExceeded
}
rawToken, err := s.tokenGenerator.New()
if err != nil {
return IssuedInvitation{}, err
}
invitation, err := s.store.InsertInvitation(ctx, InsertInvitationParams{
OrgID: orgID,
FlatID: input.FlatID,
HostUserID: hostUserID,
VisitorName: input.VisitorName,
Purpose: input.Purpose,
ValidFrom: input.ValidFrom.UTC(),
ValidUntil: input.ValidUntil.UTC(),
TokenHash: hashToken(rawToken),
})
if err != nil {
return IssuedInvitation{}, err
}
return IssuedInvitation{
Invitation: invitation,
RawToken: rawToken,
}, nil
}
type PGInvitationStore struct {
pool *pgxpool.Pool
}
func NewPGInvitationStore(pool *pgxpool.Pool) *PGInvitationStore {
return &PGInvitationStore{pool: pool}
}
// Eligibility is deliberately absent from this store. flats live in
// flat-service's database and occupancies in resident-service's; neither is
// reachable from this pool, so both questions belong to the clients below and
// PGInvitationStore keeps only visitor-owned SQL.
func (s *PGInvitationStore) PendingInvitationCount(ctx context.Context, orgID, flatID uuid.UUID) (int, error) {
var count int
err := s.pool.QueryRow(
ctx,
"SELECT count(*) "+
"FROM visitor_invitations "+
"WHERE org_id = $1 "+
"AND flat_id = $2 "+
"AND used = FALSE "+
"AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC') "+
"AND created_at < date_trunc('day', now() AT TIME ZONE 'UTC') + interval '1 day'",
orgID,
flatID,
).Scan(&count)
return count, err
}
func (s *PGInvitationStore) InsertInvitation(ctx context.Context, params InsertInvitationParams) (Invitation, error) {
var invitation Invitation
err := s.pool.QueryRow(
ctx,
"INSERT INTO visitor_invitations (org_id, flat_id, host_user_id, visitor_name, purpose, valid_from, valid_until, token_hash) "+
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8) "+
"RETURNING id, org_id, flat_id, host_user_id, visitor_name, purpose, valid_from, valid_until, token_hash, used, used_at, created_at",
params.OrgID,
params.FlatID,
params.HostUserID,
params.VisitorName,
params.Purpose,
params.ValidFrom,
params.ValidUntil,
params.TokenHash,
).Scan(
&invitation.ID,
&invitation.OrgID,
&invitation.FlatID,
&invitation.HostUserID,
&invitation.VisitorName,
&invitation.Purpose,
&invitation.ValidFrom,
&invitation.ValidUntil,
&invitation.TokenHash,
&invitation.Used,
&invitation.UsedAt,
&invitation.CreatedAt,
)
return invitation, err
}
type randomTokenGenerator struct{}
func (randomTokenGenerator) New() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func hashToken(rawToken string) string {
sum := sha256.Sum256([]byte(rawToken))
return hex.EncodeToString(sum[:])
}
func scanExists(row pgx.Row) (bool, error) {
var marker int
err := row.Scan(&marker)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}The rejection path is explicit and ordered: a foreign flat ID dies at the flat-service call with ErrFlatNotFound, an inactive resident dies at the resident-service call, and the eleventh still-pending invitation dies at the quota check — the only one of the three this service can answer from its own tables — before any INSERT happens.
Returning 403 for a flat outside the caller's org would leak that the flat exists somewhere else. The repository scopes by org_id first and the handler translates that miss to 404.
Which local check proves the quota and isolation logic
Applied exercise
Stop a stolen resident account from mass-issuing gate passes
An attacker has the credentials of a real resident in Flat B-1204 and scripts invitation creation 15 times before the host notices unusual activity.
- Trace the request path from handler to repository and identify the exact query that counts pending invitations for the flat.
- Explain why requests 1 through 10 succeed but request 11 returns an error before any INSERT occurs.
- State which HTTP response code and body you would surface to the attacker for requests 11 through 15, and why that response does not reveal anything about other tenants.
- List the two places where logging the raw token would silently undo the protection provided by token_hash.
Deliverable
A short incident note that names the enforcing method, the rejecting condition, the operator-visible response, and the token-handling mistakes that must be avoided.
Completion checks
- Your trace explicitly mentions the tenant-scoped flat existence check before host eligibility evaluation.
- The quota explanation identifies the count >= 10 branch rather than a database unique constraint as the rejection point.
- The response you propose does not leak whether any flat outside the caller's org exists.
Why is token_hash stored instead of the raw visitor token?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.