Selaa lähdekoodia

fix: a refused collection is not an empty one, and the protected list had a third copy

Two things reported from the database page, and they turned out to be the
same mistake made twice: the page answering from its own knowledge
instead of the server's.

**"projects" still shows as an ordinary collection."** The page kept its
own hard-coded list of system collections - a third copy, after the two
in the services that had already drifted apart. The server sends
`protection` for every collection in the same list call it already makes,
so the page reads that now and the copy is gone. No fixed list could have
been right in any case: a workflow's own storage is protected too, and is
named for its workflow.

**"the sessions collection is empty."** It holds 8,684 live sessions. The
server refuses to open it - it is credential material, and readable
through an API means copyable out of it - and says so in the response.
The page rendered that refusal as "No documents", which is not a
degraded answer but the opposite of the true one. It shows the server's
own words now, and does not retry a refusal three times before saying
anything.

Then the sessions themselves, which is what prompted this. They already
expire after seven days, so the count was not a leak - it is mostly
logins from this session's own testing. But the refresh path had a real
fault. It patched the token onto the record and left the expiry alone, so
a refresh near the end of a session returned a token good for another
full week pointing at a record that died the next day. Its holder was
then told the session did not exist while holding a token that verified
perfectly. The record's lifetime moves with the token it hands out now,
through upsert, because only insert and upsert carry one.

Both lifetimes also came from separate hard-coded seven-day constants
that agreed only because nobody had changed the configured value yet.
They both read it now.

Verified: the session record's expiry advanced when refreshed, where
before it would not have; the collection list reports projects as
structural, which is what the page now reads; sessions answers 403 with
its reason. 67 passed, 0 failed, tsc clean, frontend built.
fszontagh 1 kuukausi sitten
vanhempi
sitoutus
02fdaadda8

+ 21 - 7
src/webserver/auth/auth_store.cpp

@@ -246,11 +246,14 @@ Result<LoginResponse> AuthStore::login(const std::string& username,
     session.refresh_token = refresh_token;
     session.ip_address = ip_address;
     session.user_agent = user_agent;
+    // The record is kept exactly as long as the token that points at it. Both
+    // used to be seven days by separate hard-coded constants, which agreed only
+    // because nobody had changed the configured lifetime yet.
+    const int64_t session_lifetime_ms = jwt_.refreshTokenLifetimeSec() * 1000;
     session.created_at = TimeUtils::nowMs();
-    session.expires_at = session.created_at + (7 * 24 * 60 * 60 * 1000);  // 7 days
+    session.expires_at = session.created_at + session_lifetime_ms;
 
-    storage_.insert("sessions", session.toJson(), session.id,
-                   7 * 24 * 60 * 60 * 1000);  // TTL: 7 days
+    storage_.insert("sessions", session.toJson(), session.id, session_lifetime_ms);
 
     // Update last login
     nlohmann::json updates;
@@ -308,10 +311,21 @@ Result<LoginResponse> AuthStore::refreshTokens(const std::string& refresh_token)
     std::string new_access_token = jwt_.generateAccessToken(user.id, user.username, user.role);
     std::string new_refresh_token = jwt_.generateRefreshToken(user.id, user.username, user.role);
 
-    // Update session
-    nlohmann::json updates;
-    updates["refreshToken"] = new_refresh_token;
-    storage_.update("sessions", session.id, updates, 0, true);
+    // Update session.
+    //
+    // The record's lifetime has to move with the token it just handed out.
+    // Patching only the token left the record on the expiry its first insert
+    // gave it, so a refresh near the end of a session returned a token good for
+    // another full week pointing at a record that died the next day - and its
+    // holder was then told the session did not exist, while holding a token
+    // that verified perfectly.
+    //
+    // upsert rather than update because only insert and upsert carry a TTL, and
+    // upsert swaps the expiry entry in one locked server-side step.
+    const int64_t session_lifetime_ms = jwt_.refreshTokenLifetimeSec() * 1000;
+    session.refresh_token = new_refresh_token;
+    session.expires_at = TimeUtils::nowMs() + session_lifetime_ms;
+    storage_.upsert("sessions", session.toJson(), session.id, session_lifetime_ms);
 
     LoginResponse response;
     response.access_token = new_access_token;

+ 7 - 0
src/webserver/auth/jwt_utils.hpp

@@ -31,6 +31,13 @@ public:
 
     explicit JwtUtils(const Config& config);
 
+    // How long a refresh token is good for. A session record has to be kept at
+    // least as long as the token that points at it, or the token outlives its
+    // record and its holder is told the session does not exist.
+    [[nodiscard]] int64_t refreshTokenLifetimeSec() const {
+        return config_.refresh_token_lifetime_sec;
+    }
+
     // Generate access token
     std::string generateAccessToken(const std::string& user_id,
                                    const std::string& username,

+ 53 - 19
webui/src/pages/DatabasePage.tsx

@@ -40,19 +40,14 @@ interface Document {
   [key: string]: any
 }
 
-// System collections - predefined by the system (cannot be created by users)
-const SYSTEM_COLLECTIONS = [
-  'users',
-  'sessions',
-  'api_keys',
-  'credentials',
-  'collection_permissions',
-  'workflows',
-  'workflow_groups',
-  'executions',
-  'runners',
-  'nodes',
-]
+// Which collections belong to SmartBotic is the server's answer, not ours: it
+// arrives as `protection` on every collection in the list. This page used to
+// keep its own copy of the list, which is how "projects" came to be shown as an
+// ordinary collection long after the server had started protecting it - the
+// third such copy, after one in the runner and one in the webserver.
+//
+// A workflow's own storage is protected too, and is named for its workflow, so
+// no fixed list could have covered it in any case.
 
 const PAGE_SIZE = 25
 
@@ -109,12 +104,40 @@ export default function DatabasePage() {
     queryFn: () => databaseApi.listCollections(),
   })
 
-  const { data: documentsData, isLoading: documentsLoading } = useQuery({
+  // The server's verdict per collection, from the same list call. Anything it
+  // has not mentioned - a collection created a moment ago - is ordinary until
+  // it says otherwise. Declared here, above every use, because the create form
+  // consults it while the page is still rendering.
+  const protectionOf = useMemo(() => {
+    const byName: Record<string, 'none' | 'structural' | 'secret'> = {}
+    for (const detail of collectionsData?.details || []) {
+      byName[detail.name] = detail.protection || 'none'
+    }
+    // The server answers with the qualified "<project>:<collection>" name in
+    // some places and the bare one in others. Both are the same collection.
+    return (name: string) => {
+      const bare = name.includes(':') ? name.slice(name.lastIndexOf(':') + 1) : name
+      return byName[name] || byName[bare] ||
+             Object.entries(byName).find(([k]) => k.endsWith(':' + bare))?.[1] || 'none'
+    }
+  }, [collectionsData])
+
+  const { data: documentsData, isLoading: documentsLoading, error: documentsError } = useQuery({
     queryKey: ['database-documents', selectedCollection, currentPage],
     queryFn: () => databaseApi.getDocuments(selectedCollection!, currentPage, PAGE_SIZE),
     enabled: !!selectedCollection,
+    // A collection the server will not open is not going to open on the third
+    // try either, and retrying a refusal only delays saying so.
+    retry: false,
   })
 
+  // The server explains itself when it refuses; that explanation is what the
+  // reader needs, not a generic failure.
+  const documentsErrorMessage =
+    (documentsError as any)?.response?.data?.error ||
+    (documentsError as any)?.message ||
+    'This collection could not be read.'
+
   const createMutation = useMutation({
     mutationFn: ({ collection, data }: { collection: string; data: Record<string, any> }) =>
       databaseApi.createDocument(collection, data),
@@ -262,7 +285,7 @@ export default function DatabasePage() {
 
   const createCollection = () => {
     if (!newCollectionName.trim()) return
-    if (SYSTEM_COLLECTIONS.includes(newCollectionName.toLowerCase())) return
+    if (protectionOf(newCollectionName.toLowerCase()) !== 'none') return
     createCollectionMutation.mutate({
       name: newCollectionName.trim(),
       settings: collectionSettings,
@@ -409,7 +432,7 @@ export default function DatabasePage() {
     const custom: string[] = []
 
     collections.forEach(c => {
-      if (SYSTEM_COLLECTIONS.includes(c)) {
+      if (protectionOf(c) !== 'none') {
         system.push(c)
       } else {
         custom.push(c)
@@ -431,7 +454,7 @@ export default function DatabasePage() {
   })
 
   // Check if selected collection is a system collection
-  const isSystemCollection = selectedCollection ? SYSTEM_COLLECTIONS.includes(selectedCollection) : false
+  const isSystemCollection = selectedCollection ? protectionOf(selectedCollection) !== 'none' : false
 
   // Format timestamp for display (with safeguards for invalid values)
   const formatTimestamp = (ts?: number) => {
@@ -668,6 +691,16 @@ export default function DatabasePage() {
                   </button>
                 ))
               ) : (
+                documentsError ? (
+                  // A refusal is not an empty collection. "sessions" holds 8,000
+                  // live login sessions and the server answers 403 with a reason;
+                  // rendering that as "No documents" told the exact opposite of
+                  // what had happened.
+                  <div className="px-3 py-4 text-sm text-center">
+                    <Shield className="w-5 h-5 mx-auto mb-2 text-amber-500" />
+                    <p className="text-gray-600 dark:text-gray-300">{documentsErrorMessage}</p>
+                  </div>
+                ) : (
                 <p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
                   {searchTerm
                     ? 'No matching documents'
@@ -675,6 +708,7 @@ export default function DatabasePage() {
                       ? 'Created, but empty. The database does not list or return a collection until something writes to it, so this will not appear after a reload until it holds a document.'
                       : 'No documents'}
                 </p>
+                )
               )}
             </div>
             {/* Pagination */}
@@ -1096,7 +1130,7 @@ export default function DatabasePage() {
                   Only letters, numbers, underscores, and hyphens allowed
                 </p>
               </div>
-              {SYSTEM_COLLECTIONS.includes(newCollectionName.toLowerCase()) && (
+              {protectionOf(newCollectionName.toLowerCase()) !== 'none' && (
                 <div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-400">
                   This name is reserved for system collections
                 </div>
@@ -1225,7 +1259,7 @@ export default function DatabasePage() {
               </button>
               <button
                 onClick={createCollection}
-                disabled={!newCollectionName.trim() || SYSTEM_COLLECTIONS.includes(newCollectionName.toLowerCase()) || createCollectionMutation.isPending}
+                disabled={!newCollectionName.trim() || protectionOf(newCollectionName.toLowerCase()) !== 'none' || createCollectionMutation.isPending}
                 className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
               >
                 {createCollectionMutation.isPending ? 'Creating...' : 'Create Collection'}