Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
843c777
feat: add typed relations to relationship manager
Irozuku Jun 15, 2026
0279b7a
feat: track credential relations and availability in registry
Irozuku Jun 15, 2026
779ba7a
feat: add cryptography dependency
Irozuku Jun 15, 2026
39b1294
feat: add credential encryptor
Irozuku Jun 15, 2026
105d32c
docs: document decrypt InvalidToken in credential encryptor
Irozuku Jun 15, 2026
54215c6
feat: add credential database table
Irozuku Jun 15, 2026
c6fdb7c
feat: add credential store
Irozuku Jun 15, 2026
38066b2
feat: add base credential class
Irozuku Jun 15, 2026
c4e9488
feat: add huggingface, github and kaggle credentials
Irozuku Jun 15, 2026
911036c
fix: verify kaggle credential over http to avoid kaggle import side e…
Irozuku Jun 15, 2026
71d5fd1
feat: add get_credential helper to config object
Irozuku Jun 15, 2026
72a4ceb
feat: wire credential encryptor and store into container
Irozuku Jun 15, 2026
90d4a17
feat: register credentials and sync availability at startup
Irozuku Jun 15, 2026
5e3da76
feat: add credentials api endpoints
Irozuku Jun 15, 2026
0234302
fix: harden credentials api refresh scope and key safety
Irozuku Jun 15, 2026
091d931
feat: use optional huggingface credential in dataset source
Irozuku Jun 15, 2026
bae83b3
feat: add credentials api client and types
Irozuku Jun 15, 2026
634f6ae
feat: add component availability hook
Irozuku Jun 15, 2026
b2a034d
feat: add credentials translations
Irozuku Jun 15, 2026
a8f7382
feat: add credentials manager dialog and gear button
Irozuku Jun 15, 2026
27e2987
test: add credential fields to component api expectations
Irozuku Jun 15, 2026
b66ac84
fix: sync credential flags on plugin install and align migration null…
Irozuku Jun 15, 2026
daf9f70
feat: drop startup credential sync and show stored key in modal
Irozuku Jun 15, 2026
cc9abd1
fix: hide credential key by default in modal
Irozuku Jun 15, 2026
266a595
feat: refine credentials modal layout and spacing
Irozuku Jun 15, 2026
ec41021
feat: verify kaggle credential with official kaggle library
Irozuku Jun 15, 2026
1c26d69
refactor: source credential catalog from components endpoint and show…
Irozuku Jun 15, 2026
54bc830
refactor: list credentials in one request with catalog and status
Irozuku Jun 15, 2026
9833cde
feat: require huggingface credential for stable diffusion v3
Irozuku Jun 15, 2026
7314f46
feat: show credential lock on component selector cards
Irozuku Jun 15, 2026
a2e5638
feat: disable cards with unmet required credentials and mark optional…
Irozuku Jun 15, 2026
00c6ae8
feat: remove github credential from initial components
Irozuku Jun 19, 2026
fbf5ec5
feat: use key icon for credentials button
Irozuku Jun 19, 2026
b2a8b5e
test: stub credentials button in app bar test
Irozuku Jun 19, 2026
53adf9d
feat: translate credential display names and descriptions
Irozuku Jun 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions DashAI/alembic/versions/d4e8a2c6f0b1_add_credential_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Add credential table

Revision ID: d4e8a2c6f0b1
Revises: f1a2b3c4d5e6
Create Date: 2026-06-15 00:00:00.000000

"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "d4e8a2c6f0b1"
down_revision: Union[str, None] = "f1a2b3c4d5e6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"credential",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("encrypted_key", sa.Text(), nullable=False),
sa.Column(
"verified",
sa.Boolean(),
server_default="0",
nullable=False,
),
sa.Column("created", sa.DateTime(), nullable=False),
sa.Column("last_modified", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id", name="pk_credential"),
sa.UniqueConstraint("name", name="uq_credential_name"),
)


def downgrade() -> None:
op.drop_table("credential")
2 changes: 2 additions & 0 deletions DashAI/back/api/api_v1/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from DashAI.back.api.api_v1.endpoints.components import router as components
from DashAI.back.api.api_v1.endpoints.converters import router as converters
from DashAI.back.api.api_v1.endpoints.credentials import router as credentials
from DashAI.back.api.api_v1.endpoints.datafile import router as datafile_router
from DashAI.back.api.api_v1.endpoints.dataset_source import router as dataset_source
from DashAI.back.api.api_v1.endpoints.datasets import router as datasets
Expand Down Expand Up @@ -46,3 +47,4 @@
api_router_v1.include_router(dataset_source, prefix="/dataset-source")
api_router_v1.include_router(datafile_router, prefix="/datafile")
api_router_v1.include_router(folders, prefix="/folder")
api_router_v1.include_router(credentials, prefix="/credential")
265 changes: 265 additions & 0 deletions DashAI/back/api/api_v1/endpoints/credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
"""Credential API endpoints."""

import logging
from typing import TYPE_CHECKING, Any, Dict, List, Union

from fastapi import APIRouter, Depends, Header, status
from fastapi.exceptions import HTTPException
from kink import di
from pydantic import BaseModel

from DashAI.back.credentials.sync import sync_credentials_status

if TYPE_CHECKING:
from DashAI.back.dependencies.registry import ComponentRegistry

log = logging.getLogger(__name__)
router = APIRouter()


class AuthRequest(BaseModel):
"""Request body for authenticating a credential.

Parameters
----------
key : str
The platform key/token to validate and store.
"""

key: str


def _credential_components(registry: "ComponentRegistry") -> Dict[str, Dict[str, Any]]:
"""Return the registry's Credential-type components.

Parameters
----------
registry : ComponentRegistry
The component registry.

Returns
-------
Dict[str, Dict[str, Any]]
Mapping of credential name to component dict.
"""
return registry._registry.get("Credential", {})


def _localize(value: Any, language: Union[str, None]) -> Union[str, None]:
"""Resolve a possibly-multilingual value to a plain string.

Parameters
----------
value : Any
A ``MultilingualString`` or plain value.
language : Union[str, None]
The ``Accept-Language`` header value, or None.

Returns
-------
Union[str, None]
The localized string, or the value unchanged when not multilingual.
"""
if hasattr(value, "get"):
lang_code = language.split("-")[0].lower() if language else "en"
return value.get(lang_code)
return value


def _status_payload(
name: str,
component_dict: Dict[str, Any],
is_authenticated: bool,
key: Union[str, None],
language: Union[str, None] = None,
) -> Dict[str, Any]:
"""Build the status payload for a credential.

The payload bundles the catalog fields (display name, description) with the
authentication state in a single object so the configuration modal can be
populated with one request. The stored key is included so the modal can
display it, which is acceptable for DashAI's local-first, single-user
desktop model where the database already lives on the user's machine.

Parameters
----------
name : str
Credential component name.
component_dict : Dict[str, Any]
The registry component dict.
is_authenticated : bool
Whether the credential is currently verified.
key : Union[str, None]
The stored decrypted key, or None if nothing is stored.
language : Union[str, None]
The ``Accept-Language`` header used to localize text fields.

Returns
-------
Dict[str, Any]
Status payload including localized display name, description and key.
"""
display_name = _localize(component_dict.get("display_name"), language)
description = _localize(component_dict.get("description"), language)
return {
"name": name,
"display_name": display_name or name,
"description": description or "",
"is_authenticated": is_authenticated,
"key": key,
}


@router.get("/")
async def list_credentials(
accept_language: Union[str, None] = Header(default=None),
registry: "ComponentRegistry" = Depends(lambda: di["component_registry"]),
) -> List[Dict[str, Any]]:
"""List all credential components with their authentication status.

Returns catalog metadata and auth state together in a single response so
the configuration modal does not need one request per credential.

Parameters
----------
accept_language : Union[str, None]
The 'Accept-Language' header used to localize text fields.
registry : ComponentRegistry
Injected component registry.

Returns
-------
list[dict]
Credential status payloads.
"""
creds = _credential_components(registry)
store = di["credential_store"]
statuses = store.all_statuses()
return [
_status_payload(
name, cdict, statuses.get(name, False), store.load(name), accept_language
)
for name, cdict in creds.items()
]


@router.get("/{name}")
async def get_credential_status(
name: str,
accept_language: Union[str, None] = Header(default=None),
registry: "ComponentRegistry" = Depends(lambda: di["component_registry"]),
) -> Dict[str, Any]:
"""Return the status of a single credential.

Parameters
----------
name : str
Credential component name.
accept_language : Union[str, None]
The 'Accept-Language' header used to localize text fields.
registry : ComponentRegistry
Injected component registry.

Returns
-------
dict
Status payload.

Raises
------
HTTPException
404 if the credential is not registered.
"""
creds = _credential_components(registry)
if name not in creds:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Credential '{name}' not found.",
)
store = di["credential_store"]
return _status_payload(
name, creds[name], store.is_verified(name), store.load(name), accept_language
)


@router.post("/{name}/auth")
async def authenticate_credential(
name: str,
body: AuthRequest,
registry: "ComponentRegistry" = Depends(lambda: di["component_registry"]),
) -> Dict[str, Any]:
"""Verify and store a credential key.

Parameters
----------
name : str
Credential component name.
body : AuthRequest
Contains the key to authenticate with.
registry : ComponentRegistry
Injected component registry.

Returns
-------
dict
``{"is_authenticated": True}`` on success.

Raises
------
HTTPException
404 if the credential is unknown, 400 if the key is invalid.
"""
creds = _credential_components(registry)
if name not in creds:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Credential '{name}' not found.",
)
credential = creds[name]["class"]()
try:
credential.auth(body.key)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid credential key.",
) from exc

affected = registry.get_required_credentials(name)
sync_credentials_status(only=affected)
return {"is_authenticated": True}


@router.delete("/{name}")
async def delete_credential(
name: str,
registry: "ComponentRegistry" = Depends(lambda: di["component_registry"]),
) -> Dict[str, Any]:
"""Remove a stored credential key.

Parameters
----------
name : str
Credential component name.
registry : ComponentRegistry
Injected component registry.

Returns
-------
dict
``{"is_authenticated": False}``.

Raises
------
HTTPException
404 if the credential is not registered.
"""
creds = _credential_components(registry)
if name not in creds:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Credential '{name}' not found.",
)
di["credential_store"].delete(name)
affected = registry.get_required_credentials(name)
sync_credentials_status(only=affected)
return {"is_authenticated": False}
2 changes: 2 additions & 0 deletions DashAI/back/api/api_v1/endpoints/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ async def update_plugin(
Plugin
The updated plugin.
"""
from DashAI.back.credentials.sync import sync_credentials_status
from DashAI.back.plugins.utils import (
install_plugin,
register_plugin_components,
Expand Down Expand Up @@ -278,6 +279,7 @@ async def update_plugin(
# else the new components should be registered
else:
register_plugin_components(installed_components, component_registry)
sync_credentials_status()
job_queue.put(SyncComponentsJob())
elif (
plugin.status == PluginStatus.INSTALLED
Expand Down
1 change: 1 addition & 0 deletions DashAI/back/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ class DefaultSettings(BaseSettings):
EXPLANATIONS_PATH: str = "explanations"
NOTEBOOK_PATH: str = "notebook"
DATAFILE_PATH: str = "datafiles"
CREDENTIALS_KEY_PATH: str = ".credentials_key"
23 changes: 23 additions & 0 deletions DashAI/back/config_object.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from kink import di

from DashAI.back.core.schema_fields.base_schema import (
BaseSchema,
replace_defs_in_schema,
Expand Down Expand Up @@ -49,3 +51,24 @@ def validate_and_transform(self, raw_data: dict) -> dict:
"""
schema_instance = self.SCHEMA.model_validate(raw_data)
return fill_objects(schema_instance)

def get_credential(self, name: str):
"""Resolve a registered credential component by name.

The returned instance exposes ``get_key``, ``is_authenticated`` and
``apply``. When nothing is stored, ``get_key`` returns None and
``apply`` is a no-op, so optional credentials degrade gracefully.

Parameters
----------
name : str
Credential component class name (e.g. "HuggingFaceCredential").

Returns
-------
BaseCredential
An instance of the requested credential component.
"""
registry = di["component_registry"]
credential_class = registry[name]["class"]
return credential_class()
11 changes: 11 additions & 0 deletions DashAI/back/container.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import logging
import os
import pathlib
from typing import Dict

from kink import Container, di

from DashAI.back.credentials.encryptor import CredentialEncryptor, load_or_create_key
from DashAI.back.credentials.store import CredentialStore
from DashAI.back.dependencies.database import setup_sqlite_db
from DashAI.back.dependencies.job_queues.huey_job_queue import HueyJobQueue
from DashAI.back.dependencies.registry import ComponentRegistry
Expand Down Expand Up @@ -38,6 +42,13 @@ def build_container(config: Dict[str, str]) -> Container:
di["component_registry"] = ComponentRegistry(
initial_components=config["INITIAL_COMPONENTS"]
)
credentials_key = load_or_create_key(
pathlib.Path(config["CREDENTIALS_KEY_PATH"]),
env_value=os.getenv("DASHAI_CREDENTIALS_SECRET"),
)
encryptor = CredentialEncryptor(credentials_key)
di["credential_encryptor"] = encryptor
di["credential_store"] = CredentialStore(session_factory, encryptor)
job_queue = HueyJobQueue("job_queue", path_db=config["LOCAL_PATH"])

di["job_queue"] = job_queue
Expand Down
Empty file.
Loading
Loading