Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 18 additions & 1 deletion .agents/skills/baserow-registry/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,27 @@ Use `EasyImportExportMixin` for model-backed types with direct property serializ
- `parent_property_name`: the parent relation to set during import.
- `id_mapping_name`: optional mapping key to populate.
- `model_class`: the model class to create.
- `sensitive_fields`: fields omitted when `exclude_sensitive_data` is enabled.
- `sensitive_fields`: fields omitted when `exclude_sensitive_data` is enabled. Integration and user source updates also keep these fields out of the undo action log. Neither hides a field from API responses.

Override `serialize_property`, `deserialize_property`, or `create_instance_from_serialized` for file handling, ID remapping, compatibility, or custom creation.

## Integration Secrets

Credentials on an `IntegrationType` (passwords, tokens, API keys) must be write-only. Declare them on the type:

- `secret_fields`: fields that can be set and overwritten but are never returned by the API, the creator included. The response serializer replaces each one with a `has_<name>` boolean. Omitting the field on update keeps the stored value. Document that contract in the field's `help_text` through `serializer_field_extra_kwargs`.
- `secret_field_dependencies`: maps a secret to the fields that decide where it is sent. Changing any of those fields requires the secret to be supplied again in the same request, otherwise the stored credential could be sent to a destination its owner never chose.
- Also list every secret in `sensitive_fields` so it stays out of exports.

```python
class SMTPIntegrationType(IntegrationType):
sensitive_fields = ["host", "port", "use_tls", "username", "password"]
secret_fields = ["password"]
secret_field_dependencies = {"password": ["host", "port", "use_tls"]}
```

`DataSyncType.secret_field_dependencies` is the equivalent for data syncs.

## API URLs And Exceptions

Use `APIUrlsInstanceMixin` on instances that contribute API routes and `APIUrlsRegistryMixin` on the registry. Include `registry.api_urls` in the owning URL module.
Expand Down
7 changes: 5 additions & 2 deletions backend/src/baserow/api/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ def get_name(self):
def map_serializer(self, auto_schema, direction):
return self._map_serializer(auto_schema, direction, self.target.mapping)

def _map_serializer(self, auto_schema, direction, mapping):
def _map_serializer(self, auto_schema, direction, mapping, partial=False):
sub_components = []

for key, serializer_class in mapping.items():
sub_serializer = force_instance(serializer_class)
if partial:
sub_serializer.partial = True
resolved_sub_serializer = auto_schema.resolve_serializer(
sub_serializer, direction
)
Expand Down Expand Up @@ -87,7 +89,8 @@ def map_serializer(self, auto_schema, direction):
for types in self.target.registry.registry.values()
}

return self._map_serializer(auto_schema, direction, mapping)
partial = getattr(self.target, "partial_request", False)
return self._map_serializer(auto_schema, direction, mapping, partial=partial)


class DiscriminatorMappingSerializerExtension(OpenApiSerializerExtension):
Expand Down
6 changes: 6 additions & 0 deletions backend/src/baserow/api/integrations/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@
HTTP_400_BAD_REQUEST,
"The given integrations do not belong to the same application.",
)

ERROR_INTEGRATION_CREDENTIAL_REQUIRED = (
"ERROR_INTEGRATION_CREDENTIAL_REQUIRED",
HTTP_400_BAD_REQUEST,
"The credential must be provided again when the connection target changes.",
)
22 changes: 22 additions & 0 deletions backend/src/baserow/api/integrations/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from rest_framework import serializers


class HasSecretField(serializers.BooleanField):
"""
A read-only boolean that reports whether a write-only credential is set,
without disclosing its value. Used in place of the credential itself in
integration response serializers.
"""

def __init__(self, secret_field_name: str, **kwargs):
self.secret_field_name = secret_field_name
kwargs["read_only"] = True
kwargs.setdefault(
"help_text",
f"Whether the `{secret_field_name}` is set. The value itself is "
f"write-only and is never returned.",
)
super().__init__(**kwargs)

def get_attribute(self, instance):
return bool(getattr(instance, self.secret_field_name, None))
9 changes: 9 additions & 0 deletions backend/src/baserow/api/integrations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
validate_body_custom_fields,
)
from baserow.api.integrations.errors import (
ERROR_INTEGRATION_CREDENTIAL_REQUIRED,
ERROR_INTEGRATION_DOES_NOT_EXIST,
ERROR_INTEGRATION_NOT_IN_SAME_APPLICATION,
)
Expand Down Expand Up @@ -47,6 +48,7 @@
UpdateIntegrationActionType,
)
from baserow.core.integrations.exceptions import (
IntegrationCredentialRequired,
IntegrationDoesNotExist,
IntegrationNotInSameApplication,
)
Expand Down Expand Up @@ -130,6 +132,7 @@ def get(self, request, application_id):
request=DiscriminatorCustomFieldsMappingSerializer(
integration_type_registry,
CreateIntegrationSerializer,
request=True,
),
responses={
200: DiscriminatorCustomFieldsMappingSerializer(
Expand Down Expand Up @@ -191,6 +194,8 @@ class IntegrationView(APIView):
request=CustomFieldRegistryMappingSerializer(
integration_type_registry,
UpdateIntegrationSerializer,
request=True,
partial_request=True,
),
responses={
200: DiscriminatorCustomFieldsMappingSerializer(
Expand All @@ -199,6 +204,7 @@ class IntegrationView(APIView):
400: get_error_schema(
[
"ERROR_REQUEST_BODY_VALIDATION",
"ERROR_INTEGRATION_CREDENTIAL_REQUIRED",
]
),
404: get_error_schema(
Expand All @@ -212,6 +218,7 @@ class IntegrationView(APIView):
@map_exceptions(
{
IntegrationDoesNotExist: ERROR_INTEGRATION_DOES_NOT_EXIST,
IntegrationCredentialRequired: ERROR_INTEGRATION_CREDENTIAL_REQUIRED,
}
)
@require_request_data_type(dict)
Expand All @@ -229,6 +236,8 @@ def patch(self, request, integration_id: int):
integration_type_registry,
request.data,
base_serializer_class=UpdateIntegrationSerializer,
partial=True,
return_validated=True,
)

integration_updated = UpdateIntegrationActionType.do(
Expand Down
10 changes: 9 additions & 1 deletion backend/src/baserow/api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,8 @@ def get_serializer_class(
base_class = ModelSerializer

extends_meta = object
meta_extra_kwargs = meta_extra_kwargs or {}
# Copy it: the base class kwargs are merged in below, and the dict is shared.
meta_extra_kwargs = {**(meta_extra_kwargs or {})}

if hasattr(base_class, "Meta"):
extends_meta = base_class.Meta
Expand Down Expand Up @@ -449,13 +450,20 @@ def __init__(
base_class,
many=False,
request=False,
partial_request=False,
):
"""
:param partial_request: Documents every type as a partial update, for a
view that validates the request with `partial=True`.
"""

self.read_only = False
self.registry = registry
self.base_class = base_class
self.many = many
self.partial = False
self.request = request
self.partial_request = partial_request


class DiscriminatorCustomFieldsMappingSerializer:
Expand Down
1 change: 1 addition & 0 deletions backend/src/baserow/contrib/builder/preview/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class BuilderPreviewActor:

is_authenticated = True
is_anonymous = False
is_staff = False
user_source_authentication_header = "Authorization"

@property
Expand Down
3 changes: 3 additions & 0 deletions backend/src/baserow/contrib/database/data_sync/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ class DataSyncType(
request.

Example: {"postgresql_password": ["postgresql_host", "postgresql_port"]}

`IntegrationType.secret_field_dependencies` is the same concept for
integrations.
"""

sensitive_fields: List[str] = []
Expand Down
11 changes: 11 additions & 0 deletions backend/src/baserow/contrib/integrations/core/integration_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,20 @@ class SerializedDict(IntegrationDict):
serializer_field_names = ["host", "port", "use_tls", "username", "password"]
allowed_fields = ["host", "port", "use_tls", "username", "password"]
sensitive_fields = ["host", "port", "use_tls", "username", "password"]
secret_fields = ["password"]
# Changing where the request goes, or downgrading it to plaintext, would
# send the stored password somewhere its owner never agreed to.
secret_field_dependencies = {"password": ["host", "port", "use_tls"]}

request_serializer_field_names = ["host", "port", "use_tls", "username", "password"]
request_serializer_field_overrides = {}
serializer_field_extra_kwargs = {
"password": {
"help_text": "The SMTP password. Write-only: it is never returned, "
"see `has_password`. Omit it to keep the stored password; send an "
"empty string or null to clear it."
}
}

def deserialize_property(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1044,8 +1044,11 @@ def dispatch_data(
backend="django.core.mail.backends.smtp.EmailBackend",
host=smtp_integration.host,
port=smtp_integration.port,
username=smtp_integration.username,
password=smtp_integration.password,
# Django's backend replaces a None username or password with the
# instance's EMAIL_HOST_USER / EMAIL_HOST_PASSWORD, which would
# authenticate to a host the builder chose.
username=smtp_integration.username or "",
password=smtp_integration.password or "",
use_tls=smtp_integration.use_tls,
timeout=SMTP_EMAIL_TIMEOUT,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,17 @@ class SerializedDict(IntegrationDict):
serializer_field_names = ["token"]
allowed_fields = ["token"]
sensitive_fields = ["token"]
secret_fields = ["token"]

request_serializer_field_names = ["token"]
request_serializer_field_overrides = {}
serializer_field_extra_kwargs = {
"token": {
"help_text": "The Bot User OAuth Token. Write-only: it is never "
"returned, see `has_token`. Omit it on an update to keep the stored "
"token."
}
}

def import_serialized(
self,
Expand Down
8 changes: 8 additions & 0 deletions backend/src/baserow/core/integrations/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ def redo(cls, user: AbstractUser, params: Params, action_to_redo: Action):


class UpdateIntegrationActionType(UndoableActionType):
"""
Undo and redo replay the logged values without any credential. The fields
in `IntegrationType.get_action_log_excluded_fields`, which include every
target a credential protects, are never logged, so a replay cannot re-point
a stored password. If a replay does trip `IntegrationCredentialRequired`,
the action handler rolls it back and records the error on the action.
"""

type = "update_integration"
description = ActionTypeDescription(
_("Update integration"),
Expand Down
7 changes: 7 additions & 0 deletions backend/src/baserow/core/integrations/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,10 @@ class IntegrationNotInSameApplication(Exception):
Raised when trying to order integrations that that don't belong to the same
application.
"""


class IntegrationCredentialRequired(Exception):
"""
Raised when a field that controls where a request goes is changed without the
credential it protects being re-supplied in the same request.
"""
95 changes: 94 additions & 1 deletion backend/src/baserow/core/integrations/registries.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from abc import ABC
from typing import Any, Dict, Optional, Type, TypeVar
from typing import Any, Dict, List, Optional, Type, TypeVar

from django.contrib.auth.models import AbstractUser

from baserow.api.integrations.fields import HasSecretField
from baserow.core.registry import (
CustomFieldsInstanceMixin,
CustomFieldsRegistryMixin,
Expand Down Expand Up @@ -32,6 +33,30 @@ class IntegrationType(
An integration type define a specific integration with a given external service.
"""

secret_fields: List[str] = []
"""
Credentials that are write-only: they can be set and overwritten, but are
never serialized back to any user, the creator included.

This is deliberately narrower than `sensitive_fields`, which is stripped
from workspace exports and kept out of the action log, and which for some
types covers ordinary configuration such as the host and port.
"""

secret_field_dependencies: Dict[str, List[str]] = {}
"""
Maps a secret field to the request-target fields it protects. If any target
field changes value, the secret must be re-supplied in the same request,
otherwise the stored credential would be sent to a destination its owner
never chose.

Example: {"password": ["host", "port", "use_tls"]}

`DataSyncType.secret_field_dependencies` is the same concept for data syncs.
Unlike that check, this one skips a secret that is not stored, because an
integration may authenticate anonymously.
"""

def enhance_queryset(self, queryset):
"""
Allow to enhance the queryset when querying the integration mainly to improve
Expand All @@ -57,6 +82,74 @@ def prepare_values(

return values

def get_action_log_excluded_fields(self) -> List[str]:
"""
Returns the fields kept out of the update action log. Undo and redo replay
the log without any credential, so a secret and every target it protects
stay out: replaying a host change would send a password stored later to a
host someone else chose. A type that declares no secrets keeps all its
sensitive fields out, because they may hold credentials of their own,
such as the AI integration's `ai_settings`.
"""

if not self.secret_fields:
return self.sensitive_fields

targets = [
target
for targets in self.secret_field_dependencies.values()
for target in targets
]
return [*self.secret_fields, *targets]

def get_field_names(
self, request_serializer: bool, extra_params=None, **kwargs
) -> List[str]:
"""
Removes the secret fields from the response serializer and replaces each
with a `has_<name>` boolean. The request serializer keeps them, because
setting a credential is the only thing a user may do with it. A secret
the serializer does not list gets no flag.
"""

field_names = super().get_field_names(
request_serializer, extra_params, **kwargs
)

if request_serializer or not self.secret_fields:
return field_names

serialised_secrets = [n for n in field_names if n in self.secret_fields]

return [name for name in field_names if name not in self.secret_fields] + [
f"has_{name}" for name in serialised_secrets
]

def get_field_overrides(
self, request_serializer: bool, extra_params=None, **kwargs
) -> Dict:
"""
Declares the `has_<name>` booleans on the response serializer.
"""

overrides = super().get_field_overrides(
request_serializer, extra_params, **kwargs
)

if request_serializer or not self.secret_fields:
return overrides

# DRF asserts if a declared field is missing from the name list.
field_names = self.get_field_names(request_serializer, extra_params, **kwargs)
return {
**overrides,
**{
f"has_{name}": HasSecretField(name)
for name in self.secret_fields
if f"has_{name}" in field_names
},
}

def serialize_property(
self,
integration: Integration,
Expand Down
Loading
Loading