Phase 5A: implement tenant auth foundation across broker, app, and agent
This commit is contained in:
@@ -111,20 +111,22 @@ Status: ✅ Completed (2026-05-18)
|
||||
- Off-LAN or failed direct attempts still open fullscreen photo through broker fallback.
|
||||
- User-facing fullscreen flow remains one tap with no extra prompts.
|
||||
|
||||
## Phase 5: Platform Integration & Hardening
|
||||
## Phase 5A: Multi-user Foundation (Google + Tenant Isolation)
|
||||
|
||||
- Align Linux service + Windows task launchers with new network/config settings.
|
||||
- Add rate limits and upload size checks on LAN endpoint.
|
||||
- Add migration/upload concurrency controls.
|
||||
- Add operational logs/metrics for:
|
||||
- Auth failures
|
||||
- Direct vs broker upload ratio
|
||||
- Migration outcomes and duration.
|
||||
- Introduce explicit ownership in data model:
|
||||
- `users`, `agents`, `photos` keyed by `user_id`
|
||||
- One agent owned by exactly one user
|
||||
- Scope all photo/agent API reads and writes by authenticated `user_id`.
|
||||
- Add Google auth entry flow for app users:
|
||||
- Android Google sign-in
|
||||
- Broker verifies Google ID token
|
||||
- Broker issues short-lived app JWT plus refresh/session token
|
||||
- Migrate existing single-user records into one seeded owner account.
|
||||
|
||||
### Acceptance
|
||||
|
||||
- Windows and Linux agents behave consistently with LAN mode enabled.
|
||||
- Security and reliability checks pass in repeated real-network tests.
|
||||
- Cross-user access is blocked on all covered endpoints.
|
||||
- Existing single-user records are migrated to one seeded owner account.
|
||||
|
||||
## Public Interfaces / Config Additions
|
||||
|
||||
|
||||
32
PLAN.md
32
PLAN.md
@@ -12,7 +12,7 @@ that works end-to-end, not a half-finished layer.
|
||||
| 0 | POC — tunnel, upload relay, reverse fetch | ✅ Done |
|
||||
| 1 | Android app — pick and upload photos | ✅ Done |
|
||||
| 2 | Agent UI v1 — status + storage settings (LAN API/UI) | ✅ Done (2026-05-18) |
|
||||
| TODO | Remaining work backlog (phase grouping TBD) | ⬜ Pending |
|
||||
| 5A | Multi-user foundation (Google auth + tenant boundaries) | ⬜ Pending |
|
||||
|
||||
---
|
||||
|
||||
@@ -55,6 +55,28 @@ that works end-to-end, not a half-finished layer.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5A — Multi-user Foundation (Google Auth + Ownership)
|
||||
|
||||
**Goal:** Move from single-user assumptions to shared broker operation with strict
|
||||
tenant isolation by authenticated user identity.
|
||||
|
||||
**Ownership model:**
|
||||
- `users`, `agents`, and `photos` have explicit `user_id` ownership boundaries
|
||||
- One agent is owned by exactly one user
|
||||
- All photo/agent API reads and writes are scoped by authenticated `user_id`
|
||||
|
||||
**Auth entry:**
|
||||
- Android app uses Google sign-in
|
||||
- Broker verifies Google ID token
|
||||
- Broker issues app JWT (short-lived) and refresh/session token
|
||||
|
||||
### Acceptance
|
||||
|
||||
- Cross-user access is blocked on all covered endpoints.
|
||||
- Existing single-user records are migrated to one seeded owner account.
|
||||
|
||||
---
|
||||
|
||||
## TODO (Post-Phase 2 Backlog)
|
||||
|
||||
Phase grouping beyond Phase 1 is intentionally deferred. Use this backlog to
|
||||
@@ -83,12 +105,8 @@ pick what will become the next implementation phase(s).
|
||||
- [ ] Basic pagination or infinite scroll
|
||||
|
||||
### Multi-user foundation
|
||||
- [ ] Data model boundaries: `users`, `agents`, `photos` with explicit ownership keys
|
||||
- [ ] Add `user_id` scoping to all photo/agent queries and APIs
|
||||
- [ ] Auth contracts: user JWT + per-agent token format and lifecycle
|
||||
- [ ] Pairing flow design: agent token issue, rotate, revoke
|
||||
- [ ] Endpoint auth matrix (public/private, required scopes)
|
||||
- [ ] Migration plan from current single-user data to multi-user-safe schema
|
||||
- [ ] Phase 5A scoped in `## Phase 5A — Multi-user Foundation (Google Auth + Ownership)`
|
||||
- [ ] Remaining auth/pairing lifecycle design follows Phase 5B (to be scoped next)
|
||||
|
||||
### Android backup and release
|
||||
- [ ] WorkManager job — runs on WiFi, respects battery saver
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -917,13 +918,17 @@ func main() {
|
||||
|
||||
go state.startHTTPServer(cfg.AllowedSubnets)
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(brokerURL, nil)
|
||||
wsURL := brokerURL
|
||||
if state.token != "" {
|
||||
wsURL = brokerURL + "?token=" + url.QueryEscape(state.token)
|
||||
}
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
log.Fatal("connect:", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
state.setConnected(true)
|
||||
log.Println("connected to broker at", brokerURL)
|
||||
log.Println("connected to broker at", wsURL)
|
||||
hello, _ := json.Marshal(Message{
|
||||
Type: "agent_hello",
|
||||
LANEndpoints: getLANEndpoints(cfg.BindHost, cfg.BindPort),
|
||||
|
||||
@@ -81,11 +81,16 @@ class _UploadPageState extends State<UploadPage> {
|
||||
Timer? _statusTimer;
|
||||
DirectUploadConfig _directConfig = DirectUploadConfig.defaults;
|
||||
late final TextEditingController _tokenController;
|
||||
late final TextEditingController _googleIdTokenController;
|
||||
String _accessToken = '';
|
||||
String _refreshToken = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tokenController = TextEditingController();
|
||||
_googleIdTokenController = TextEditingController();
|
||||
_loadBrokerSession();
|
||||
_loadDirectUploadConfig();
|
||||
_loadGallery();
|
||||
_statusTimer = Timer.periodic(
|
||||
@@ -98,9 +103,95 @@ class _UploadPageState extends State<UploadPage> {
|
||||
void dispose() {
|
||||
_statusTimer?.cancel();
|
||||
_tokenController.dispose();
|
||||
_googleIdTokenController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadBrokerSession() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_accessToken = prefs.getString('broker_access_token') ?? '';
|
||||
_refreshToken = prefs.getString('broker_refresh_token') ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveBrokerSession({
|
||||
required String accessToken,
|
||||
required String refreshToken,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('broker_access_token', accessToken);
|
||||
await prefs.setString('broker_refresh_token', refreshToken);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_accessToken = accessToken;
|
||||
_refreshToken = refreshToken;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _clearBrokerSession() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('broker_access_token');
|
||||
await prefs.remove('broker_refresh_token');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_accessToken = '';
|
||||
_refreshToken = '';
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _signInWithGoogleIdToken() async {
|
||||
final idToken = _googleIdTokenController.text.trim();
|
||||
if (idToken.isEmpty) return false;
|
||||
|
||||
try {
|
||||
final res = await http.post(
|
||||
Uri.parse('$brokerUrl/auth/google'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'id_token': idToken}),
|
||||
);
|
||||
if (res.statusCode != 200) return false;
|
||||
final body = jsonDecode(res.body);
|
||||
final accessToken = (body['access_token'] ?? '').toString();
|
||||
final refreshToken = (body['refresh_token'] ?? '').toString();
|
||||
if (accessToken.isEmpty || refreshToken.isEmpty) return false;
|
||||
await _saveBrokerSession(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _getValidAccessToken() async {
|
||||
if (_accessToken.isNotEmpty) return _accessToken;
|
||||
if (_refreshToken.isEmpty) return null;
|
||||
try {
|
||||
final res = await http.post(
|
||||
Uri.parse('$brokerUrl/auth/refresh'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'refresh_token': _refreshToken}),
|
||||
);
|
||||
if (res.statusCode != 200) {
|
||||
await _clearBrokerSession();
|
||||
return null;
|
||||
}
|
||||
final body = jsonDecode(res.body);
|
||||
final accessToken = (body['access_token'] ?? '').toString();
|
||||
if (accessToken.isEmpty) return null;
|
||||
await _saveBrokerSession(
|
||||
accessToken: accessToken,
|
||||
refreshToken: _refreshToken,
|
||||
);
|
||||
return accessToken;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadDirectUploadConfig() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cfg = DirectUploadConfig(
|
||||
@@ -207,7 +298,12 @@ class _UploadPageState extends State<UploadPage> {
|
||||
|
||||
Future<List<Uri>> _fetchAgentHints() async {
|
||||
try {
|
||||
final res = await http.get(Uri.parse('$brokerUrl/agent/hints'));
|
||||
final token = await _getValidAccessToken();
|
||||
if (token == null) return const [];
|
||||
final res = await http.get(
|
||||
Uri.parse('$brokerUrl/agent/hints'),
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
);
|
||||
if (res.statusCode != 200) return const [];
|
||||
final body = jsonDecode(res.body);
|
||||
if (body is! Map<String, dynamic>) return const [];
|
||||
@@ -244,11 +340,16 @@ class _UploadPageState extends State<UploadPage> {
|
||||
}
|
||||
|
||||
Future<http.StreamedResponse> _uploadViaBroker(File file) async {
|
||||
final token = await _getValidAccessToken();
|
||||
if (token == null) {
|
||||
throw const HttpException('missing broker auth session');
|
||||
}
|
||||
final filename = p.basename(file.path);
|
||||
final req = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$brokerUrl/upload/$filename'),
|
||||
);
|
||||
req.headers['Authorization'] = 'Bearer $token';
|
||||
req.files.add(await http.MultipartFile.fromPath('file', file.path));
|
||||
return req.send().timeout(const Duration(seconds: 20));
|
||||
}
|
||||
@@ -271,12 +372,15 @@ class _UploadPageState extends State<UploadPage> {
|
||||
|
||||
Future<void> _indexDirectUploadToBroker(File file) async {
|
||||
try {
|
||||
final token = await _getValidAccessToken();
|
||||
if (token == null) return;
|
||||
final thumbBytes = await _buildPhoneThumbnail(file);
|
||||
if (thumbBytes == null) return;
|
||||
final req = http.MultipartRequest(
|
||||
'POST',
|
||||
Uri.parse('$brokerUrl/upload-index'),
|
||||
);
|
||||
req.headers['Authorization'] = 'Bearer $token';
|
||||
req.fields['filename'] = p.basename(file.path);
|
||||
req.fields['size_bytes'] = (await file.length()).toString();
|
||||
req.fields['content_type'] = 'image/jpeg';
|
||||
@@ -317,7 +421,18 @@ class _UploadPageState extends State<UploadPage> {
|
||||
});
|
||||
try {
|
||||
await _refreshAgentStatus();
|
||||
final res = await http.get(Uri.parse('$brokerUrl/photos'));
|
||||
final token = await _getValidAccessToken();
|
||||
if (token == null) {
|
||||
setState(() {
|
||||
_galleryLoading = false;
|
||||
_galleryError = 'Sign in is required to load gallery.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
final res = await http.get(
|
||||
Uri.parse('$brokerUrl/photos'),
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
);
|
||||
if (res.statusCode != 200) {
|
||||
setState(() {
|
||||
_galleryLoading = false;
|
||||
@@ -329,7 +444,7 @@ class _UploadPageState extends State<UploadPage> {
|
||||
final body = jsonDecode(res.body);
|
||||
final items = _extractPhotoItems(body);
|
||||
final photos = items
|
||||
.map(_toGalleryPhoto)
|
||||
.map((x) => _toGalleryPhoto(x, token))
|
||||
.whereType<GalleryPhoto>()
|
||||
.toList(growable: false);
|
||||
|
||||
@@ -361,7 +476,17 @@ class _UploadPageState extends State<UploadPage> {
|
||||
|
||||
Future<void> _refreshAgentStatus() async {
|
||||
try {
|
||||
final res = await http.get(Uri.parse('$brokerUrl/'));
|
||||
final token = await _getValidAccessToken();
|
||||
if (token == null) {
|
||||
if (mounted) {
|
||||
setState(() => _agentConnected = false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final res = await http.get(
|
||||
Uri.parse('$brokerUrl/agent/hints'),
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
);
|
||||
if (res.statusCode != 200) {
|
||||
if (mounted) {
|
||||
setState(() => _agentConnected = false);
|
||||
@@ -392,11 +517,11 @@ class _UploadPageState extends State<UploadPage> {
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
GalleryPhoto? _toGalleryPhoto(Map<String, dynamic> item) {
|
||||
GalleryPhoto? _toGalleryPhoto(Map<String, dynamic> item, String accessToken) {
|
||||
final thumbRaw = item['thumbnail_url'];
|
||||
if (thumbRaw is! String) return null;
|
||||
|
||||
final thumb = _resolveUrl(thumbRaw);
|
||||
final thumb = _resolveUrl(thumbRaw, accessToken);
|
||||
if (thumb.isEmpty) return null;
|
||||
|
||||
final rawFilename = item['filename'];
|
||||
@@ -407,7 +532,7 @@ class _UploadPageState extends State<UploadPage> {
|
||||
String full;
|
||||
|
||||
if (explicitFull is String && explicitFull.trim().isNotEmpty) {
|
||||
full = _resolveUrl(explicitFull);
|
||||
full = _resolveUrl(explicitFull, accessToken);
|
||||
} else if (filename != null) {
|
||||
final encoded = Uri.encodeComponent(filename);
|
||||
full = '$brokerUrl/photo/$encoded';
|
||||
@@ -424,12 +549,15 @@ class _UploadPageState extends State<UploadPage> {
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveUrl(String value) {
|
||||
String _resolveUrl(String value, String accessToken) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return '';
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri != null && uri.hasScheme && uri.hasAuthority) return trimmed;
|
||||
return Uri.parse(brokerUrl).resolve(trimmed).toString();
|
||||
final resolved = (uri != null && uri.hasScheme && uri.hasAuthority)
|
||||
? trimmed
|
||||
: Uri.parse(brokerUrl).resolve(trimmed).toString();
|
||||
final sep = resolved.contains('?') ? '&' : '?';
|
||||
return '$resolved${sep}access_token=${Uri.encodeQueryComponent(accessToken)}';
|
||||
}
|
||||
|
||||
Future<void> _openFullPhoto(GalleryPhoto photo) async {
|
||||
@@ -580,6 +708,42 @@ class _UploadPageState extends State<UploadPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_accessToken.isEmpty
|
||||
? 'Broker session: not signed in'
|
||||
: 'Broker session: signed in',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _googleIdTokenController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Google ID token',
|
||||
helperText: 'Paste Google ID token, then sign in.',
|
||||
),
|
||||
obscureText: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FilledButton(
|
||||
onPressed: () async {
|
||||
final ok = await _signInWithGoogleIdToken();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
ok ? 'Broker sign-in successful.' : 'Broker sign-in failed.',
|
||||
),
|
||||
),
|
||||
);
|
||||
if (ok) {
|
||||
await _loadGallery();
|
||||
}
|
||||
},
|
||||
child: const Text('Sign In'),
|
||||
),
|
||||
),
|
||||
const Divider(height: 20),
|
||||
const Text(
|
||||
'Direct upload is automatic using broker-provided agent LAN hints. '
|
||||
'If direct fails, upload falls back to broker.',
|
||||
|
||||
2
memory
2
memory
Submodule memory updated: f4ddac7a07...ab50b9a0f5
Reference in New Issue
Block a user