MotorSignal-App/firestore.rules

645 lines
27 KiB
Plaintext

rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function signedIn() {
return request.auth != null;
}
function ownsUser(userId) {
return signedIn() && request.auth.uid == userId;
}
function validUserCreate(userId) {
return ownsUser(userId)
&& request.resource.data.id == userId
&& request.resource.data.keys().hasOnly([
'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio',
'location', 'createdAt', 'updatedAt', 'privacySettings', 'preferences'
]);
}
function validUserUpdate(userId) {
return ownsUser(userId)
&& request.resource.data.id == resource.data.id
&& request.resource.data.createdAt == resource.data.createdAt
&& request.resource.data.keys().hasOnly([
'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio',
'location', 'createdAt', 'updatedAt', 'privacySettings', 'preferences'
]);
}
function validPublicProfileCreate(userId) {
return ownsUser(userId)
&& request.resource.data.id == userId
&& request.resource.data.keys().hasOnly([
'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio',
'location', 'isSearchableByHandle', 'isProfilePublic', 'allowFriendRequests',
'updatedAt'
]);
}
function validPublicProfileUpdate(userId) {
return ownsUser(userId)
&& request.resource.data.id == resource.data.id
&& request.resource.data.keys().hasOnly([
'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio',
'location', 'isSearchableByHandle', 'isProfilePublic', 'allowFriendRequests',
'updatedAt'
]);
}
function validRideCreate(userId, rideId) {
return ownsUser(userId)
&& request.resource.data.ownerId == userId
&& request.resource.data.id == rideId;
}
function validRideUpdate(userId) {
return ownsUser(userId)
&& resource.data.ownerId == userId
&& request.resource.data.ownerId == resource.data.ownerId
&& request.resource.data.id == resource.data.id;
}
function isBlockedBy(sourceUid, targetUid) {
return exists(/databases/$(database)/documents/users/$(targetUid)/blocks/$(sourceUid));
}
function areMutuallyBlocked(userA, userB) {
return isBlockedBy(userA, userB) || isBlockedBy(userB, userA);
}
// --- HANDLE RESERVATIONS ---
// Internal uniqueness index. Read access is guarded to prevent
// turning handle lookup into an open user enumeration directory.
match /handles/{handle} {
allow list: if false;
allow get: if signedIn() && (
resource == null ||
resource.data.uid == request.auth.uid || (
!areMutuallyBlocked(request.auth.uid, resource.data.uid) &&
get(/databases/$(database)/documents/public_profiles/$(resource.data.uid)).data.isSearchableByHandle == true
)
);
allow create: if signedIn()
&& request.resource.data.uid == request.auth.uid
&& request.resource.data.handle == handle;
allow delete: if signedIn() && resource.data.uid == request.auth.uid;
allow update: if false;
}
// --- PUBLIC PROFILES ---
// Contains ONLY safe public fields. No email, tokens, rides, or billing.
match /public_profiles/{userId} {
allow list: if false;
allow get: if signedIn() && (
ownsUser(userId) || (
!areMutuallyBlocked(request.auth.uid, userId) &&
(
resource.data.isSearchableByHandle == true ||
exists(/databases/$(database)/documents/users/$(userId)/friends/$(request.auth.uid))
)
)
);
allow create: if validPublicProfileCreate(userId);
allow update: if validPublicProfileUpdate(userId);
allow delete: if ownsUser(userId);
}
// --- PRIVATE USER ACCOUNTS ---
// Strictly private to the account owner.
match /users/{userId} {
allow create: if validUserCreate(userId);
allow read: if ownsUser(userId);
allow update: if validUserUpdate(userId);
allow delete: if ownsUser(userId);
match /rides/{rideId} {
allow create: if validRideCreate(userId, rideId);
allow update: if validRideUpdate(userId);
allow list: if ownsUser(userId);
allow get, delete: if ownsUser(userId) && resource.data.ownerId == userId;
match /events/{eventId} {
allow read, write: if ownsUser(userId);
}
match /performance/{runId} {
allow read, write: if ownsUser(userId);
}
}
match /motorcycles/{motorcycleId} {
allow create, update: if ownsUser(userId)
&& request.resource.data.ownerId == userId;
allow read, delete: if ownsUser(userId);
}
match /trips/{tripId} {
allow read, write: if ownsUser(userId);
}
match /events/{eventId} {
allow read, write: if ownsUser(userId);
}
// --- SOCIAL SUBCOLLECTIONS ---
match /friends/{friendId} {
allow read: if ownsUser(userId);
allow create: if signedIn()
&& (request.auth.uid == userId || request.auth.uid == friendId)
&& userId != friendId
&& !areMutuallyBlocked(userId, friendId)
&& (
(request.auth.uid == userId && exists(/databases/$(database)/documents/users/$(userId)/friend_requests_incoming/$(friendId))) ||
(request.auth.uid == friendId && exists(/databases/$(database)/documents/users/$(friendId)/friend_requests_incoming/$(userId)))
);
allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == friendId);
allow update: if false;
}
match /friend_requests_incoming/{senderId} {
allow read: if ownsUser(userId);
allow create: if signedIn()
&& request.auth.uid == senderId
&& senderId != userId
&& request.resource.data.receiverUid == userId
&& request.resource.data.senderUid == senderId
&& !areMutuallyBlocked(userId, senderId)
&& !exists(/databases/$(database)/documents/users/$(userId)/friends/$(senderId))
&& get(/databases/$(database)/documents/public_profiles/$(userId)).data.allowFriendRequests == true;
allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == senderId);
allow update: if false;
}
match /friend_requests_outgoing/{receiverId} {
allow read: if ownsUser(userId);
allow create: if ownsUser(userId)
&& receiverId != userId
&& request.resource.data.receiverUid == receiverId
&& request.resource.data.senderUid == userId
&& !areMutuallyBlocked(userId, receiverId);
allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == receiverId);
allow update: if false;
}
match /blocks/{blockedId} {
allow read, write: if ownsUser(userId);
}
}
// --- GROUP RIDES ---
// Top-level social object distinct from personal rides, accounts, and telemetry.
match /group_rides/{groupRideId} {
function isOrganizer() {
return signedIn() && resource.data.organizerUid == request.auth.uid;
}
function hasParticipantAccess(uid) {
return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status in ['invited', 'accepted'];
}
function isFriendOfOrganizer() {
return signedIn() && exists(/databases/$(database)/documents/users/$(resource.data.organizerUid)/friends/$(request.auth.uid));
}
function canReadRide() {
return signedIn() && (
isOrganizer() ||
(
!areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && (
hasParticipantAccess(request.auth.uid) ||
resource.data.visibility == 'public' ||
(resource.data.visibility == 'friendsOnly' && isFriendOfOrganizer())
)
)
);
}
allow get: if canReadRide();
allow list: if signedIn() && (
resource.data.organizerUid == request.auth.uid ||
(
'accessUids' in resource.data &&
request.auth.uid in resource.data.accessUids
)
);
allow create: if signedIn()
&& request.resource.data.organizerUid == request.auth.uid
&& request.resource.data.id == groupRideId
&& request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint', 'accessUids'])
&& request.resource.data.accessUids == [request.auth.uid]
&& request.resource.data.participantCount == 1
&& (
!('routeId' in request.resource.data) ||
request.resource.data.routeId == null ||
(
exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) &&
get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid
)
);
function validStatusTransition() {
let prev = resource.data.status;
let next = request.resource.data.status;
return prev == next ||
(prev == 'scheduled' && next in ['assembling', 'underway', 'canceled']) ||
(prev == 'assembling' && next in ['underway', 'canceled']) ||
(prev == 'underway' && next in ['completed', 'canceled']);
}
function validRouteAttachment() {
return !('routeId' in request.resource.data) ||
request.resource.data.routeId == null ||
(
(resource != null && 'routeId' in resource.data && request.resource.data.routeId == resource.data.routeId) ||
(
exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) &&
get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid
)
);
}
function validOrganizerRideUpdate() {
let hasPrevAccess = 'accessUids' in resource.data;
let hasNextAccess = 'accessUids' in request.resource.data;
let prevAccess = hasPrevAccess ? resource.data.accessUids : [resource.data.organizerUid];
let nextAccess = hasNextAccess ? request.resource.data.accessUids : prevAccess;
let prevCount = 'participantCount' in resource.data ? resource.data.participantCount : 1;
let nextCount = 'participantCount' in request.resource.data ? request.resource.data.participantCount : prevCount;
let added = nextAccess.removeAll(prevAccess);
let removed = prevAccess.removeAll(nextAccess);
let countDelta = nextCount - prevCount;
return validStatusTransition() && validRouteAttachment() && (
(added.size() == 0 && removed.size() == 0 && countDelta == 0) ||
(added.size() == 1 && removed.size() == 0 && countDelta == 0) ||
(added.size() == 1 && removed.size() == 0 && countDelta == 1) ||
(added.size() == 0 && removed.size() == 1 && (countDelta == -1 || countDelta == 0))
);
}
function validParticipantRideUpdate() {
let hasPrevAccess = 'accessUids' in resource.data;
let hasNextAccess = 'accessUids' in request.resource.data;
let prevAccess = hasPrevAccess ? resource.data.accessUids : [resource.data.organizerUid];
let nextAccess = hasNextAccess ? request.resource.data.accessUids : prevAccess;
let prevCount = 'participantCount' in resource.data ? resource.data.participantCount : 1;
let nextCount = 'participantCount' in request.resource.data ? request.resource.data.participantCount : prevCount;
let added = nextAccess.removeAll(prevAccess);
let removed = prevAccess.removeAll(nextAccess);
let countDelta = nextCount - prevCount;
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['accessUids', 'participantCount', 'updatedAt']) &&
canReadRide() &&
(
// Join open ride: caller added to accessUids, count +1
(
resource.data.joinPolicy == 'open' &&
added == [request.auth.uid] &&
removed.size() == 0 &&
countDelta == 1
) ||
// Accept invite: caller was already in accessUids, count +1
(
request.auth.uid in prevAccess &&
added.size() == 0 &&
removed.size() == 0 &&
countDelta == 1
) ||
// Decline invite or leave: caller removed from accessUids, count unchanged (declined) or count -1 (left accepted)
(
request.auth.uid in prevAccess &&
removed == [request.auth.uid] &&
added.size() == 0 &&
(countDelta == -1 || countDelta == 0)
)
);
}
allow update: if signedIn() && (
(isOrganizer() && validOrganizerRideUpdate()) ||
validParticipantRideUpdate()
);
allow delete: if isOrganizer();
// --- PARTICIPANTS SUBCOLLECTION ---
match /participants/{participantUid} {
function rideDoc() {
return exists(/databases/$(database)/documents/group_rides/$(groupRideId))
? get(/databases/$(database)/documents/group_rides/$(groupRideId))
: (existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
? getAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
: null);
}
function rideOrganizer() {
return rideDoc().data.organizerUid;
}
function isAcceptedParticipant(uid) {
return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted';
}
// Collection listing is strictly disallowed (point-read architecture over known accessUids)
allow list: if false;
// Single-document read: self, organizer, or accepted peer (if not mutually blocked)
allow get: if signedIn() && (
request.auth.uid == participantUid ||
request.auth.uid == rideOrganizer() ||
(
isAcceptedParticipant(request.auth.uid) &&
!areMutuallyBlocked(request.auth.uid, participantUid)
)
);
// Organizer can invite riders; caller can request to join or join open ride; caller can initialize own organizer record on create
allow create: if signedIn() && (
// Self-organizer initialization on ride create
(
request.auth.uid == participantUid &&
request.auth.uid == rideOrganizer() &&
request.resource.data.role == 'organizer' &&
request.resource.data.status == 'accepted'
) ||
// Organizer inviting a non-blocked rider
(
request.auth.uid == rideOrganizer() &&
request.resource.data.status == 'invited' &&
request.resource.data.role == 'participant' &&
!areMutuallyBlocked(request.auth.uid, participantUid)
) ||
// Rider requesting to join (joinPolicy == requestToJoin)
(
request.auth.uid == participantUid &&
request.auth.uid != rideOrganizer() &&
rideDoc().data.joinPolicy == 'requestToJoin' &&
request.resource.data.status == 'requested' &&
request.resource.data.role == 'participant' &&
!areMutuallyBlocked(request.auth.uid, rideOrganizer())
) ||
// Rider joining open ride (joinPolicy == open)
(
request.auth.uid == participantUid &&
request.auth.uid != rideOrganizer() &&
rideDoc().data.joinPolicy == 'open' &&
request.resource.data.status == 'accepted' &&
request.resource.data.role == 'participant' &&
!areMutuallyBlocked(request.auth.uid, rideOrganizer())
)
);
// State transitions: participant can accept/decline/leave; organizer can approve/decline/remove or transfer
allow update: if signedIn() && (
// Invitee accepting own invitation (invited -> accepted)
(
request.auth.uid == participantUid &&
resource.data.status == 'invited' &&
request.resource.data.status == 'accepted' &&
request.resource.data.role == resource.data.role
) ||
// Invitee declining own invitation (invited -> declined)
(
request.auth.uid == participantUid &&
resource.data.status == 'invited' &&
request.resource.data.status == 'declined' &&
request.resource.data.role == resource.data.role
) ||
// Participant leaving (accepted -> left)
(
request.auth.uid == participantUid &&
resource.data.status == 'accepted' &&
request.resource.data.status == 'left' &&
request.resource.data.role == resource.data.role
) ||
// Organizer approving request (requested -> accepted)
(
request.auth.uid == rideOrganizer() &&
resource.data.status == 'requested' &&
request.resource.data.status == 'accepted'
) ||
// Organizer declining request (requested -> declined)
(
request.auth.uid == rideOrganizer() &&
resource.data.status == 'requested' &&
request.resource.data.status == 'declined'
) ||
// Organizer removing participant (status -> removed)
(
request.auth.uid == rideOrganizer() &&
request.resource.data.status == 'removed'
) ||
// Organizer role transfer
(
request.auth.uid == rideOrganizer() &&
request.resource.data.role in ['organizer', 'participant']
)
);
allow delete: if signedIn() && (
request.auth.uid == participantUid ||
request.auth.uid == rideOrganizer()
);
}
// --- LIVE PRESENCE SUBCOLLECTION ---
// Ephemeral location and status sharing during an active or assembling ride.
match /live_presence/{presenceUid} {
function rideDoc() {
return exists(/databases/$(database)/documents/group_rides/$(groupRideId))
? get(/databases/$(database)/documents/group_rides/$(groupRideId))
: (existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
? getAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
: null);
}
function isAcceptedParticipant(uid) {
return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted';
}
function rideIsLiveEligible() {
return rideDoc().data.status in ['scheduled', 'assembling', 'underway'];
}
function validCoordinates(data) {
return data.latitude >= -90.0 && data.latitude <= 90.0 &&
data.longitude >= -180.0 && data.longitude <= 180.0;
}
function validPresenceKeys(data) {
return data.keys().hasAll(['userId', 'groupRideId', 'latitude', 'longitude', 'updatedAt', 'expiresAt', 'sharingEnabled', 'status']) &&
data.keys().hasOnly(['userId', 'groupRideId', 'latitude', 'longitude', 'heading', 'speedMph', 'accuracyMeters', 'updatedAt', 'expiresAt', 'sharingEnabled', 'status']);
}
// Collection listing is strictly disallowed
allow list: if false;
// READ (SINGLE DOCUMENT GET ONLY):
// Self access: permitted as required by own-presence lifecycle / cleanup.
// Peer read: caller accepted, target accepted, ride live-eligible, sharing enabled, not mutually blocked.
allow get: if signedIn() && (
request.auth.uid == presenceUid ||
(
isAcceptedParticipant(request.auth.uid) &&
isAcceptedParticipant(presenceUid) &&
rideIsLiveEligible() &&
resource.data.sharingEnabled == true &&
!areMutuallyBlocked(request.auth.uid, presenceUid)
)
);
// WRITE (CREATE/UPDATE):
// Caller must write only their own document, must be an accepted participant,
// ride must be live-eligible, valid coordinates, valid schema keys,
// request.resource.data.userId == request.auth.uid and groupRideId == groupRideId.
allow create, update: if signedIn()
&& request.auth.uid == presenceUid
&& request.resource.data.userId == request.auth.uid
&& request.resource.data.groupRideId == groupRideId
&& isAcceptedParticipant(request.auth.uid)
&& rideIsLiveEligible()
&& validCoordinates(request.resource.data)
&& validPresenceKeys(request.resource.data);
// DELETE:
// Rider can delete their own live presence record, or ride organizer during cleanup.
allow delete: if signedIn() && (
request.auth.uid == presenceUid ||
request.auth.uid == rideDoc().data.organizerUid
);
}
}
// --- ROUTES ---
// User-created motorcycle routes (standalone or attached to group rides).
match /routes/{routeId} {
function isRouteOwner() {
return signedIn() && resource.data.ownerUid == request.auth.uid;
}
function isAcceptedGroupParticipant(groupRideId, uid) {
return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted';
}
function canReadRoute() {
return signedIn() && (
isRouteOwner() ||
(
!areMutuallyBlocked(request.auth.uid, resource.data.ownerUid) && (
resource.data.visibility == 'public' ||
(
resource.data.visibility == 'groupOnly' &&
resource.data.attachedGroupRideId != null &&
isAcceptedGroupParticipant(resource.data.attachedGroupRideId, request.auth.uid)
)
)
)
);
}
// Validates embedded stop representation.
// Note: Client/domain validation additionally enforces unique stop IDs, sequential 0..N-1 indexing,
// and exhaustive validation of all stops across large routes.
// Exhaustive checking of all stops across large arrays is bounded in Firestore Rules due to expression limits.
function validStop(stop) {
return stop is map
&& stop.id is string && stop.id.size() >= 1 && stop.id.size() <= 64
&& stop.displayName is string && stop.displayName.size() >= 1 && stop.displayName.size() <= 80
&& (stop.latitude is int || stop.latitude is float) && stop.latitude >= -90.0 && stop.latitude <= 90.0
&& (stop.longitude is int || stop.longitude is float) && stop.longitude >= -180.0 && stop.longitude <= 180.0
&& (
('stopType' in stop && stop.stopType in ['start', 'origin', 'meetup', 'waypoint', 'fuel', 'rest', 'food', 'scenic', 'regroup', 'destination', 'other']) ||
('type' in stop && stop.type in ['start', 'origin', 'meetup', 'waypoint', 'fuel', 'rest', 'food', 'scenic', 'regroup', 'destination', 'other'])
);
}
function validStops(stops) {
return stops is list
&& stops.size() <= 25
&& (stops.size() < 1 || validStop(stops[0]))
&& (stops.size() < 2 || validStop(stops[1]))
&& (stops.size() < 3 || validStop(stops[2]))
&& (stops.size() < 4 || validStop(stops[3]))
&& (stops.size() < 5 || validStop(stops[4]));
}
function validRouteKeys(data) {
return data.keys().hasAll(['id', 'ownerUid', 'title', 'stops', 'createdAt', 'updatedAt'])
&& data.keys().hasOnly([
'id', 'ownerUid', 'ownerHandle', 'title', 'description',
'visibility', 'stops', 'straightLineDistanceMeters',
'estimatedDistanceMeters', 'estimatedDurationSeconds',
'source', 'version', 'attachedGroupRideId', 'createdAt', 'updatedAt'
]);
}
function validRouteCommon(data) {
return validRouteKeys(data)
&& data.title is string
&& data.title.size() >= 3
&& data.title.size() <= 80
&& (!('ownerHandle' in data) || (data.ownerHandle is string && data.ownerHandle.size() <= 40))
&& (!('description' in data) || (data.description is string && data.description.size() <= 500))
&& (!('visibility' in data) || data.visibility in ['private', 'groupOnly', 'public'])
&& (
!('visibility' in data) ||
data.visibility != 'groupOnly' ||
(('attachedGroupRideId' in data) && data.attachedGroupRideId is string && data.attachedGroupRideId.size() > 0)
)
&& (
!('attachedGroupRideId' in data) ||
data.attachedGroupRideId == null ||
(
data.attachedGroupRideId is string &&
data.attachedGroupRideId.size() > 0 &&
data.attachedGroupRideId.size() <= 64 &&
exists(/databases/$(database)/documents/group_rides/$(data.attachedGroupRideId)) &&
get(/databases/$(database)/documents/group_rides/$(data.attachedGroupRideId)).data.organizerUid == request.auth.uid
)
)
&& (!('straightLineDistanceMeters' in data) || ((data.straightLineDistanceMeters is int || data.straightLineDistanceMeters is float) && data.straightLineDistanceMeters >= 0))
&& (!('estimatedDistanceMeters' in data) || data.estimatedDistanceMeters == null || ((data.estimatedDistanceMeters is int || data.estimatedDistanceMeters is float) && data.estimatedDistanceMeters >= 0))
&& (!('estimatedDurationSeconds' in data) || data.estimatedDurationSeconds == null || (data.estimatedDurationSeconds is int && data.estimatedDurationSeconds >= 0))
&& (!('source' in data) || data.source in ['manual', 'planner', 'gpxImport', 'groupRideConversion'])
&& (!('version' in data) || (data.version is int && data.version >= 1))
&& validStops(data.stops);
}
function validRouteCreate() {
return signedIn()
&& request.resource.data.id == routeId
&& request.resource.data.ownerUid == request.auth.uid
&& validRouteCommon(request.resource.data);
}
function validRouteUpdate() {
return isRouteOwner()
&& request.resource.data.id == resource.data.id
&& request.resource.data.ownerUid == resource.data.ownerUid
&& request.resource.data.createdAt == resource.data.createdAt
&& validRouteCommon(request.resource.data);
}
allow read: if canReadRoute();
allow create: if validRouteCreate();
allow update: if validRouteUpdate();
allow delete: if isRouteOwner();
}
match /{document=**} {
allow read, write: if false;
}
}
}