368 lines
15 KiB
Plaintext
368 lines
15 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 isParticipant() {
|
|
return signedIn() && exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid));
|
|
}
|
|
|
|
function isFriendOfOrganizer() {
|
|
return signedIn() && exists(/databases/$(database)/documents/users/$(resource.data.organizerUid)/friends/$(request.auth.uid));
|
|
}
|
|
|
|
function canReadRide() {
|
|
return signedIn() && (
|
|
// Organizer or active participant can always read if not mutually blocked
|
|
(isOrganizer() || isParticipant()) ||
|
|
(
|
|
!areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && (
|
|
resource.data.visibility == 'public' ||
|
|
(resource.data.visibility == 'friendsOnly' && isFriendOfOrganizer())
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
allow read: if canReadRide();
|
|
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'])
|
|
&& request.resource.data.participantCount >= 1;
|
|
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']);
|
|
}
|
|
|
|
allow update: if signedIn() && (
|
|
// Organizer can update ride authority fields with valid status transitions
|
|
(isOrganizer() && validStatusTransition()) ||
|
|
// Participants/invitees can only atomically adjust participantCount during join/leave transactions
|
|
(
|
|
canReadRide() &&
|
|
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['participantCount', 'updatedAt'])
|
|
)
|
|
);
|
|
allow delete: if isOrganizer();
|
|
|
|
// --- PARTICIPANTS SUBCOLLECTION ---
|
|
match /participants/{participantUid} {
|
|
function rideDoc() {
|
|
return get(/databases/$(database)/documents/group_rides/$(groupRideId));
|
|
}
|
|
|
|
function rideOrganizer() {
|
|
return rideDoc().data.organizerUid;
|
|
}
|
|
|
|
allow read: if signedIn() && (
|
|
request.auth.uid == participantUid ||
|
|
request.auth.uid == rideOrganizer() ||
|
|
exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)) ||
|
|
rideDoc().data.visibility == 'public' ||
|
|
(rideDoc().data.visibility == 'friendsOnly' && exists(/databases/$(database)/documents/users/$(rideOrganizer())/friends/$(request.auth.uid)))
|
|
);
|
|
|
|
// 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.resource.data.role == 'organizer') ||
|
|
// Organizer inviting a non-blocked rider
|
|
(
|
|
request.auth.uid == rideOrganizer() &&
|
|
request.resource.data.status == 'invited' &&
|
|
!areMutuallyBlocked(request.auth.uid, participantUid)
|
|
) ||
|
|
// Rider requesting to join or joining open ride
|
|
(
|
|
request.auth.uid == participantUid &&
|
|
(request.resource.data.status == 'requested' || request.resource.data.status == 'accepted') &&
|
|
!areMutuallyBlocked(request.auth.uid, rideOrganizer())
|
|
)
|
|
);
|
|
|
|
// State transitions: participant can accept/decline/leave; organizer can approve/decline/remove or transfer
|
|
allow update: if signedIn() && (
|
|
// Participant responding to invite or leaving
|
|
(
|
|
request.auth.uid == participantUid &&
|
|
(
|
|
request.resource.data.status in ['accepted', 'declined', 'left'] ||
|
|
// Transfer target becoming organizer
|
|
(request.resource.data.role == 'organizer' && request.auth.uid == rideOrganizer())
|
|
)
|
|
) ||
|
|
// Organizer approving, removing, or transferring
|
|
(
|
|
request.auth.uid == rideOrganizer()
|
|
)
|
|
);
|
|
|
|
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 get(/databases/$(database)/documents/group_rides/$(groupRideId));
|
|
}
|
|
|
|
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']);
|
|
}
|
|
|
|
// READ:
|
|
// Caller must be signed in, be an accepted participant of this ride, target must be accepted participant,
|
|
// ride must be live-eligible, and either caller is reading own presence or (sharingEnabled == true and caller not mutually blocked with target).
|
|
allow read: if signedIn()
|
|
&& isAcceptedParticipant(request.auth.uid)
|
|
&& isAcceptedParticipant(presenceUid)
|
|
&& rideIsLiveEligible()
|
|
&& (
|
|
request.auth.uid == presenceUid ||
|
|
(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
|
|
);
|
|
}
|
|
}
|
|
|
|
match /{document=**} {
|
|
allow read, write: if false;
|
|
}
|
|
}
|
|
}
|