fred-core 3.4.4__tar.gz → 3.4.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fred_core-3.4.4 → fred_core-3.4.7}/PKG-INFO +1 -1
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/__init__.py +9 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/common/__init__.py +2 -0
- fred_core-3.4.7/fred_core/common/gcs_client.py +52 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/common/utils.py +2 -1
- fred_core-3.4.7/fred_core/conversion/__init__.py +25 -0
- fred_core-3.4.7/fred_core/conversion/pptx_pdf.py +147 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/filesystem/gcs_filesystem.py +6 -6
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/models.py +11 -2
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/oidc.py +53 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/rebac/rebac_engine.py +105 -4
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/store/__init__.py +2 -0
- fred_core-3.4.7/fred_core/store/gcs_content_store.py +197 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/common/test_fastapi_handlers.py +35 -1
- fred_core-3.4.7/fred_core/tests/common/test_gcs_client.py +72 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/common/test_lru_cache.py +4 -2
- fred_core-3.4.7/fred_core/tests/conversion/test_pptx_pdf.py +137 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/filesystem/test_gcs_filesystem.py +3 -3
- fred_core-3.4.7/fred_core/tests/security/test_oidc_token_lifetime.py +139 -0
- fred_core-3.4.7/fred_core/tests/security/test_rebac_engine_team_helpers.py +544 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/security/test_service_agent.py +3 -2
- fred_core-3.4.7/fred_core/tests/store/test_gcs_content_store.py +187 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core.egg-info/PKG-INFO +1 -1
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core.egg-info/SOURCES.txt +8 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/pyproject.toml +2 -2
- fred_core-3.4.4/fred_core/tests/security/test_rebac_engine_team_helpers.py +0 -265
- {fred_core-3.4.4 → fred_core-3.4.7}/README.md +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/cli/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/cli/auth.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/cli/ui.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/common/config_files.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/common/config_loader.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/common/env.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/common/fastapi_handlers.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/common/lru_cache.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/common/resilient_sink.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/common/structures.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/common/team_id.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/documents/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/documents/document_models.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/documents/document_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/documents/document_structures.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/documents/postgres_document_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/documents/tag_models.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/filesystem/local_filesystem.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/filesystem/minio_filesystem.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/filesystem/structures.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/history/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/history/base_history_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/history/history_models.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/history/history_schema.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/history/postgres_history_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/kpi/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/kpi/base_kpi_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/kpi/base_kpi_writer.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/kpi/http_middleware.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/kpi/kpi_factory.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/kpi/kpi_phase_metric.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/kpi/kpi_process.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/kpi/kpi_reader_structures.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/kpi/kpi_writer.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/kpi/kpi_writer_structures.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/kpi/log_kpi_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/kpi/noop_kpi_writer.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/kpi/opensearch_kpi_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/kpi/prometheus_kpi_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/logs/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/logs/audit_log.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/logs/base_log_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/logs/log_setup.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/logs/log_store_factory.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/logs/log_structures.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/logs/memory_log_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/logs/null_log_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/logs/opensearch_log_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/model/factory.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/model/http_clients.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/model/models.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/models/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/models/base.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/portable/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/portable/observability.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/py.typed +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/scheduler/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/scheduler/backend.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/scheduler/scheduler_structures.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/scheduler/temporal_client_provider.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/authorization.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/backend_to_backend_auth.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/keycloak/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/keycloak/keycloack_admin_client.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/outbound.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/rebac/noop_engine.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/rebac/openfga_engine.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/rebac/openfga_schema.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/rebac/rebac_factory.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/rebac/schema.fga +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/rebac/schema.fga.json +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/structure.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/security/whitelist_access_control/access_control.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/session/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/session/session_schema.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/session/stores/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/session/stores/base_session_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/session/stores/postgres_session_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/session/stores/session_models.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/sql/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/sql/alembic_env.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/sql/async_session.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/sql/base_sql.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/sql/mixin.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/store/base_content_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/store/local_content_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/store/minio_content_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/store/opensearch_mapping_validator.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/store/vector_search.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tasks/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tasks/authz.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tasks/bus.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tasks/models.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tasks/orm_models.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tasks/service.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tasks/sse.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tasks/store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tasks/workflow_control.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/teams/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/teams/metadata_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/teams/team_metatada_models.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/cli/test_auth.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/cli/test_ui.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/common/test_config_loader.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/common/test_env.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/common/test_log_setup.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/common/test_resilient_sink.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/common/test_structures.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/filesystem/test_local_filesystem.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/integration/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/integration/test_rebac.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/kpi/test_http_middleware.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/kpi/test_noop_kpi_writer.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/kpi/test_opensearch_kpi_store_anonymise.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/kpi/test_opensearch_kpi_store_ensure_ready.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/logs/test_audit_log.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/logs/test_log_store_factory.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/logs/test_memory_log_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/logs/test_opensearch_log_store_ensure_ready.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/model/test_anthropic_factory.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/model/test_embedding_factory.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/model/test_http_clients.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/model/test_vertex_model_garden_auth.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/portable/test_observability.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/scheduler/test_backend.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/scheduler/test_temporal_client_provider.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/security/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/security/test_capability_scoping_1980.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/security/test_oidc_strict.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/security/test_rebac_config_defaults.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/security/test_rebac_schema_authz05.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/security/test_security_profile.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/security/test_whitelist_access_control.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/session/test_postgres_json_session_store_sqlite.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/store/test_local_content_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/store/test_vector_search.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/tasks/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/tasks/test_authz.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/tasks/test_bus.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/tasks/test_models.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/tasks/test_reconcile.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/tasks/test_scheduling.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/tasks/test_sse.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/test_kpi_writer_fail_open.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/test_log_kpi_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/tests/test_prometheus_kpi_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/users/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/users/store/__init__.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/users/store/base_user_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/users/store/postgres_user_store.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core/users/user_models.py +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core.egg-info/dependency_links.txt +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core.egg-info/requires.txt +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/fred_core.egg-info/top_level.txt +0 -0
- {fred_core-3.4.4 → fred_core-3.4.7}/setup.cfg +0 -0
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
12
|
# See the License for the specific language governing permissions and
|
|
13
13
|
# limitations under the License.
|
|
14
|
+
from fred_core.conversion import (
|
|
15
|
+
DEFAULT_PPTX_PDF_TIMEOUT_SECONDS,
|
|
16
|
+
convert_pptx_bytes_to_pdf,
|
|
17
|
+
convert_pptx_file_to_pdf,
|
|
18
|
+
)
|
|
14
19
|
from fred_core.documents import (
|
|
15
20
|
AccessInfo,
|
|
16
21
|
BaseDocumentMetadataStore,
|
|
@@ -208,6 +213,10 @@ __all__ = [
|
|
|
208
213
|
"GcuVersionsType",
|
|
209
214
|
"get_user_store",
|
|
210
215
|
"get_config",
|
|
216
|
+
# conversion
|
|
217
|
+
"DEFAULT_PPTX_PDF_TIMEOUT_SECONDS",
|
|
218
|
+
"convert_pptx_bytes_to_pdf",
|
|
219
|
+
"convert_pptx_file_to_pdf",
|
|
211
220
|
"TeamMetadata",
|
|
212
221
|
"TeamMetadataPatch",
|
|
213
222
|
"TeamMetadataStore",
|
|
@@ -20,6 +20,7 @@ from .config_loader import (
|
|
|
20
20
|
)
|
|
21
21
|
from .env import coerce_bool, read_env_bool
|
|
22
22
|
from .fastapi_handlers import register_exception_handlers
|
|
23
|
+
from .gcs_client import build_gcs_client
|
|
23
24
|
from .lru_cache import ThreadSafeLRUCache
|
|
24
25
|
from .resilient_sink import ResilientSinkStore
|
|
25
26
|
from .structures import (
|
|
@@ -64,6 +65,7 @@ __all__ = [
|
|
|
64
65
|
"ResilientSinkStore",
|
|
65
66
|
"TemporalSchedulerConfig",
|
|
66
67
|
"ThreadSafeLRUCache",
|
|
68
|
+
"build_gcs_client",
|
|
67
69
|
"coerce_bool",
|
|
68
70
|
"load_configuration_with_config_files",
|
|
69
71
|
"parse_yaml_mapping_file",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Copyright Thales 2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import google.auth
|
|
20
|
+
from google.api_core.client_options import ClientOptions
|
|
21
|
+
from google.cloud import storage
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_gcs_client(project_id: Optional[str] = None) -> storage.Client:
|
|
25
|
+
"""Build a GCS client whose universe domain matches the loaded ADC credentials.
|
|
26
|
+
|
|
27
|
+
Why this exists:
|
|
28
|
+
- Bare ``storage.Client(project=project_id)`` implicitly assumes the
|
|
29
|
+
``googleapis.com`` universe domain. Under Trusted Partner Cloud /
|
|
30
|
+
sovereign deployments (e.g. S3NS, the Thales/Google joint venture cloud
|
|
31
|
+
in France), Workload Identity Federation ADC credentials declare a
|
|
32
|
+
different universe domain (e.g. ``s3nsapis.fr``), and the client raises
|
|
33
|
+
``google.api_core.universe.UniverseMismatchError`` the moment it tries
|
|
34
|
+
to authenticate: "The configured universe domain (googleapis.com) does
|
|
35
|
+
not match the universe domain found in the credentials (s3nsapis.fr)."
|
|
36
|
+
- Explicitly loading ADC and mirroring ``credentials.universe_domain``
|
|
37
|
+
into ``client_options`` makes every GCS-backed store portable across
|
|
38
|
+
public GCP and sovereign GCP variants with zero extra configuration —
|
|
39
|
+
the client always trusts whatever universe the credentials actually
|
|
40
|
+
belong to instead of assuming the public default.
|
|
41
|
+
|
|
42
|
+
Used by every native-GCS backend (fred-core's content store and virtual
|
|
43
|
+
filesystem, knowledge-flow's content store and file store) so the fix
|
|
44
|
+
lives in one place.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
credentials, adc_project = google.auth.default()
|
|
48
|
+
return storage.Client(
|
|
49
|
+
project=project_id or adc_project,
|
|
50
|
+
credentials=credentials,
|
|
51
|
+
client_options=ClientOptions(universe_domain=credentials.universe_domain),
|
|
52
|
+
)
|
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
|
|
15
15
|
import logging
|
|
16
16
|
import uuid
|
|
17
|
+
from typing import NoReturn
|
|
17
18
|
|
|
18
19
|
from fastapi import HTTPException
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
def raise_internal_error(logger: logging.Logger, msg: str, exc: Exception):
|
|
22
|
+
def raise_internal_error(logger: logging.Logger, msg: str, exc: Exception) -> NoReturn:
|
|
22
23
|
"""
|
|
23
24
|
Raise a FastAPI HTTPException (500) while logging the full exception with a unique error ID.
|
|
24
25
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright Thales 2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from fred_core.conversion.pptx_pdf import (
|
|
16
|
+
DEFAULT_PPTX_PDF_TIMEOUT_SECONDS,
|
|
17
|
+
convert_pptx_bytes_to_pdf,
|
|
18
|
+
convert_pptx_file_to_pdf,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DEFAULT_PPTX_PDF_TIMEOUT_SECONDS",
|
|
23
|
+
"convert_pptx_bytes_to_pdf",
|
|
24
|
+
"convert_pptx_file_to_pdf",
|
|
25
|
+
]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Copyright Thales 2026
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Shared, best-effort PPTX→PDF conversion using headless LibreOffice.
|
|
16
|
+
|
|
17
|
+
Why this lives in ``fred-core``:
|
|
18
|
+
- Both Knowledge Flow (slide vision enrichment) and the Agentic PPT-filler preview
|
|
19
|
+
need the exact same ``soffice`` invocation. Keeping one implementation here means
|
|
20
|
+
there is a single place to harden (timeout, error handling) instead of two drifting
|
|
21
|
+
``subprocess.run`` calls.
|
|
22
|
+
|
|
23
|
+
Contract:
|
|
24
|
+
- The conversion is **best-effort**: any failure (missing ``soffice``, a non-zero exit,
|
|
25
|
+
a timeout, or no PDF produced) returns ``None`` (for the bytes helper) rather than
|
|
26
|
+
raising through the caller. Callers decide how to degrade — the preview pane, for
|
|
27
|
+
instance, still returns the ``.pptx`` when the PDF cannot be produced.
|
|
28
|
+
- The bytes helper (:func:`convert_pptx_bytes_to_pdf`) is **async and bounded**: it runs
|
|
29
|
+
``soffice`` off the event loop via ``asyncio.to_thread`` with a timeout, so it is safe
|
|
30
|
+
to call from an async tool without stalling the turn.
|
|
31
|
+
|
|
32
|
+
``soffice`` is expected to be installed in both backend images.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
import logging
|
|
39
|
+
import shutil
|
|
40
|
+
import subprocess # nosec: controlled command arguments, shell=False
|
|
41
|
+
import tempfile
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
# LibreOffice can occasionally hang (font server, first-run profile init). A bounded
|
|
47
|
+
# timeout keeps a stuck conversion from stalling an async agent turn indefinitely.
|
|
48
|
+
DEFAULT_PPTX_PDF_TIMEOUT_SECONDS = 60.0
|
|
49
|
+
|
|
50
|
+
# Same export filter the Knowledge Flow processor has used in production: embed standard
|
|
51
|
+
# fonts and pin the PDF version so the rendered deck is faithful across viewers.
|
|
52
|
+
_PDF_EXPORT_FILTER = "pdf:writer_pdf_Export:EmbedStandardFonts=True,SelectPdfVersion=1"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def convert_pptx_file_to_pdf(
|
|
56
|
+
pptx_path: Path,
|
|
57
|
+
timeout_seconds: float = DEFAULT_PPTX_PDF_TIMEOUT_SECONDS,
|
|
58
|
+
) -> Path | None:
|
|
59
|
+
"""Convert a ``.pptx`` file to a PDF next to it using headless LibreOffice.
|
|
60
|
+
|
|
61
|
+
Blocking; returns the produced ``.pdf`` path, or ``None`` when ``soffice`` is
|
|
62
|
+
missing, fails, times out, or produces no file. Prefer :func:`convert_pptx_bytes_to_pdf`
|
|
63
|
+
from async code — this variant exists for synchronous callers (e.g. the Knowledge Flow
|
|
64
|
+
slide renderer) that already own a temp directory.
|
|
65
|
+
"""
|
|
66
|
+
pdf_path = pptx_path.with_suffix(".pdf")
|
|
67
|
+
soffice_path = shutil.which("soffice")
|
|
68
|
+
if not soffice_path:
|
|
69
|
+
logger.error(
|
|
70
|
+
"[PPTX2PDF] LibreOffice (soffice) is not installed or not in PATH."
|
|
71
|
+
)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
subprocess.run( # nosec: controlled command arguments, shell=False
|
|
76
|
+
[
|
|
77
|
+
soffice_path,
|
|
78
|
+
"--headless",
|
|
79
|
+
"--nologo",
|
|
80
|
+
"--nofirststartwizard",
|
|
81
|
+
"--convert-to",
|
|
82
|
+
_PDF_EXPORT_FILTER,
|
|
83
|
+
"--outdir",
|
|
84
|
+
str(pptx_path.parent),
|
|
85
|
+
str(pptx_path),
|
|
86
|
+
],
|
|
87
|
+
check=True,
|
|
88
|
+
stdout=subprocess.PIPE,
|
|
89
|
+
stderr=subprocess.PIPE,
|
|
90
|
+
timeout=timeout_seconds,
|
|
91
|
+
)
|
|
92
|
+
except subprocess.TimeoutExpired:
|
|
93
|
+
logger.error(
|
|
94
|
+
"[PPTX2PDF] LibreOffice conversion timed out after %.0fs.", timeout_seconds
|
|
95
|
+
)
|
|
96
|
+
return None
|
|
97
|
+
except subprocess.CalledProcessError as exc:
|
|
98
|
+
logger.error(
|
|
99
|
+
"[PPTX2PDF] LibreOffice conversion failed: %s",
|
|
100
|
+
exc.stderr.decode(errors="ignore") if exc.stderr else exc,
|
|
101
|
+
)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
if pdf_path.exists():
|
|
105
|
+
logger.info("[PPTX2PDF] Converted PPTX to PDF: %s", pdf_path)
|
|
106
|
+
return pdf_path
|
|
107
|
+
|
|
108
|
+
logger.warning("[PPTX2PDF] Conversion completed but PDF not found: %s", pdf_path)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def convert_pptx_bytes_to_pdf(
|
|
113
|
+
pptx_bytes: bytes,
|
|
114
|
+
timeout_seconds: float = DEFAULT_PPTX_PDF_TIMEOUT_SECONDS,
|
|
115
|
+
) -> bytes | None:
|
|
116
|
+
"""Convert in-memory ``.pptx`` bytes to PDF bytes, off the event loop and bounded.
|
|
117
|
+
|
|
118
|
+
Returns the PDF bytes, or ``None`` when the conversion is unavailable or fails (missing
|
|
119
|
+
``soffice``, non-zero exit, timeout, or no PDF produced). Never raises for a conversion
|
|
120
|
+
problem, so a caller can treat the preview as best-effort.
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
```python
|
|
124
|
+
pdf = await convert_pptx_bytes_to_pdf(filled_bytes)
|
|
125
|
+
if pdf is None:
|
|
126
|
+
... # keep the .pptx, skip the preview
|
|
127
|
+
```
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def _run() -> bytes | None:
|
|
131
|
+
with tempfile.TemporaryDirectory(prefix="pptx2pdf-") as tmp:
|
|
132
|
+
pptx_path = Path(tmp) / "deck.pptx"
|
|
133
|
+
pptx_path.write_bytes(pptx_bytes)
|
|
134
|
+
pdf_path = convert_pptx_file_to_pdf(
|
|
135
|
+
pptx_path, timeout_seconds=timeout_seconds
|
|
136
|
+
)
|
|
137
|
+
if pdf_path is None:
|
|
138
|
+
return None
|
|
139
|
+
return pdf_path.read_bytes()
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
return await asyncio.to_thread(_run)
|
|
143
|
+
except (
|
|
144
|
+
Exception
|
|
145
|
+
): # pragma: no cover - defensive: never let conversion break the caller
|
|
146
|
+
logger.exception("[PPTX2PDF] Unexpected error during PPTX→PDF conversion.")
|
|
147
|
+
return None
|
|
@@ -17,12 +17,12 @@ import re
|
|
|
17
17
|
import time
|
|
18
18
|
from typing import List, Optional, Set
|
|
19
19
|
|
|
20
|
+
from fred_core.common.gcs_client import build_gcs_client
|
|
20
21
|
from fred_core.filesystem.structures import (
|
|
21
22
|
BaseFilesystem,
|
|
22
23
|
FilesystemResourceInfo,
|
|
23
24
|
FilesystemResourceInfoResult,
|
|
24
25
|
)
|
|
25
|
-
from google.cloud import storage
|
|
26
26
|
from google.cloud.exceptions import NotFound
|
|
27
27
|
|
|
28
28
|
logger = logging.getLogger(__name__)
|
|
@@ -67,9 +67,7 @@ class GcsFilesystem(BaseFilesystem):
|
|
|
67
67
|
# Prefix injected externally by the app if needed (mirrors MinioFilesystem).
|
|
68
68
|
self.prefix: str | None = None
|
|
69
69
|
|
|
70
|
-
self.client = (
|
|
71
|
-
storage.Client(project=project_id) if project_id else storage.Client()
|
|
72
|
-
)
|
|
70
|
+
self.client = build_gcs_client(project_id)
|
|
73
71
|
self.bucket = self.client.bucket(bucket_name)
|
|
74
72
|
|
|
75
73
|
def health_check(self) -> dict:
|
|
@@ -222,12 +220,14 @@ class GcsFilesystem(BaseFilesystem):
|
|
|
222
220
|
except NotFound:
|
|
223
221
|
logger.warning("[GCS_DELETE] already gone: %s", blob.name)
|
|
224
222
|
|
|
225
|
-
# Delete the object itself and a possible directory marker.
|
|
223
|
+
# Delete the object itself and a possible directory marker. The marker in
|
|
224
|
+
# particular may simply never have existed, so NotFound here is routine,
|
|
225
|
+
# not warning-worthy.
|
|
226
226
|
for key in (resolved, prefix):
|
|
227
227
|
try:
|
|
228
228
|
self.bucket.blob(key).delete()
|
|
229
229
|
except NotFound:
|
|
230
|
-
|
|
230
|
+
logger.debug("[GCS_DELETE] already gone: %s", key)
|
|
231
231
|
|
|
232
232
|
async def print_root_dir(self) -> str:
|
|
233
233
|
"""Return the logical root URI in ``gs://bucket/prefix`` form."""
|
|
@@ -63,8 +63,17 @@ class Resource(str, Enum):
|
|
|
63
63
|
ORGANIZATION = "organization"
|
|
64
64
|
|
|
65
65
|
|
|
66
|
-
class AuthorizationError(
|
|
67
|
-
"""Raised when a user is not authorized to perform an action.
|
|
66
|
+
class AuthorizationError(PermissionError):
|
|
67
|
+
"""Raised when a user is not authorized to perform an action.
|
|
68
|
+
|
|
69
|
+
Inherits from the builtin `PermissionError` (not bare `Exception`) so that
|
|
70
|
+
every call site's `except PermissionError` — the standard ReBAC-denial ->
|
|
71
|
+
403 mapping — also catches this without needing its own `except
|
|
72
|
+
AuthorizationError` clause. Without this, a denial raised via
|
|
73
|
+
`check_permission_or_raise` (which raises `AuthorizationError`) falls
|
|
74
|
+
through to a generic `except Exception` handler and surfaces as an
|
|
75
|
+
unhandled 500 instead of 403 wherever only `PermissionError` is caught.
|
|
76
|
+
"""
|
|
68
77
|
|
|
69
78
|
def __init__(
|
|
70
79
|
self,
|
|
@@ -50,6 +50,15 @@ CLOCK_SKEW_SECONDS = int(os.getenv("FRED_JWT_CLOCK_SKEW", "0")) # optional leew
|
|
|
50
50
|
JWT_CACHE_ENABLED = read_env_bool("FRED_JWT_CACHE_ENABLED", default=True)
|
|
51
51
|
JWT_CACHE_TTL_SECONDS = int(os.getenv("FRED_JWT_CACHE_TTL", "60"))
|
|
52
52
|
JWT_CACHE_MAX_SIZE = int(os.getenv("FRED_JWT_CACHE_SIZE", "512"))
|
|
53
|
+
# Application-side ceiling on token lifetime (exp - iat), independent of
|
|
54
|
+
# whatever an IdP was configured to issue. Unlike STRICT_ISSUER/STRICT_AUDIENCE
|
|
55
|
+
# (opt-in, gated behind the c3 profile, never enabled by any config in this
|
|
56
|
+
# repo), this stays on by default: a misconfigured or unfamiliar IdP issuing
|
|
57
|
+
# long-lived tokens should not be silently trusted. Fred's own M2M provider
|
|
58
|
+
# (`backend_to_backend_auth.py`) already mints short-lived, auto-refreshed
|
|
59
|
+
# tokens per call, so this does not affect normal service-to-service traffic
|
|
60
|
+
# — raise the env var if a deployment genuinely needs a longer ceiling.
|
|
61
|
+
MAX_TOKEN_LIFETIME_SECONDS = int(os.getenv("FRED_JWT_MAX_LIFETIME_SECONDS", "3600"))
|
|
53
62
|
|
|
54
63
|
# Initialize global variables (to be set later)
|
|
55
64
|
KEYCLOAK_ENABLED = False
|
|
@@ -404,6 +413,50 @@ def decode_jwt(token: str) -> KeycloakUser:
|
|
|
404
413
|
headers={"WWW-Authenticate": "Bearer error='invalid_token'"},
|
|
405
414
|
)
|
|
406
415
|
|
|
416
|
+
# Defense-in-depth ceiling on token lifetime, independent of the issuing
|
|
417
|
+
# IdP's own configuration (see MAX_TOKEN_LIFETIME_SECONDS docstring above).
|
|
418
|
+
# `verify_exp` above only rejects a token that has already expired; this
|
|
419
|
+
# additionally rejects one that was never supposed to live this long in
|
|
420
|
+
# the first place, regardless of whether it currently happens to still be
|
|
421
|
+
# valid.
|
|
422
|
+
iat, exp = payload.get("iat"), payload.get("exp")
|
|
423
|
+
if isinstance(iat, (int, float)) and isinstance(exp, (int, float)):
|
|
424
|
+
lifetime_seconds = exp - iat
|
|
425
|
+
if lifetime_seconds < 0:
|
|
426
|
+
# `verify_exp` above only checks exp against "now" — it does not
|
|
427
|
+
# relate exp to iat, so a token claiming iat after its own exp
|
|
428
|
+
# (malformed, or a confused/misconfigured IdP) can still pass it
|
|
429
|
+
# as long as exp itself is still in the future. That negative
|
|
430
|
+
# lifetime would otherwise fail the ">" ceiling check below
|
|
431
|
+
# silently instead of being rejected.
|
|
432
|
+
logger.warning(
|
|
433
|
+
"[AUTH] JWT has negative lifetime (exp before iat): iat=%s exp=%s sub=%s",
|
|
434
|
+
iat,
|
|
435
|
+
exp,
|
|
436
|
+
payload.get("sub"),
|
|
437
|
+
)
|
|
438
|
+
raise HTTPException(
|
|
439
|
+
status_code=401,
|
|
440
|
+
detail="Token has invalid iat/exp claims",
|
|
441
|
+
headers={
|
|
442
|
+
"WWW-Authenticate": "Bearer error='invalid_token', error_description='exp before iat'"
|
|
443
|
+
},
|
|
444
|
+
)
|
|
445
|
+
if lifetime_seconds > MAX_TOKEN_LIFETIME_SECONDS:
|
|
446
|
+
logger.warning(
|
|
447
|
+
"[AUTH] JWT lifetime exceeds policy ceiling: lifetime=%ss max=%ss sub=%s",
|
|
448
|
+
lifetime_seconds,
|
|
449
|
+
MAX_TOKEN_LIFETIME_SECONDS,
|
|
450
|
+
payload.get("sub"),
|
|
451
|
+
)
|
|
452
|
+
raise HTTPException(
|
|
453
|
+
status_code=401,
|
|
454
|
+
detail="Token lifetime exceeds the maximum permitted duration",
|
|
455
|
+
headers={
|
|
456
|
+
"WWW-Authenticate": "Bearer error='invalid_token', error_description='token lifetime too long'"
|
|
457
|
+
},
|
|
458
|
+
)
|
|
459
|
+
|
|
407
460
|
# Extract client roles
|
|
408
461
|
client_roles = []
|
|
409
462
|
if "resource_access" in payload:
|
|
@@ -21,6 +21,7 @@ from dataclasses import dataclass
|
|
|
21
21
|
from enum import Enum
|
|
22
22
|
from typing import Iterable
|
|
23
23
|
|
|
24
|
+
from fred_core.common.team_id import is_personal_team_id, personal_team_id
|
|
24
25
|
from fred_core.logs.audit_log import emit_audit_log
|
|
25
26
|
from fred_core.security.models import AuthorizationError, Resource
|
|
26
27
|
from fred_core.security.structure import KeycloakUser
|
|
@@ -166,9 +167,21 @@ class TeamPermission(str, Enum):
|
|
|
166
167
|
|
|
167
168
|
# Team permissions a `service_agent` caller is allowed to satisfy without an OpenFGA
|
|
168
169
|
# relation (RFC EVAL-AUTH, Solution A). Read-only: the evaluation worker executes
|
|
169
|
-
# agents
|
|
170
|
-
# through to the normal ReBAC check and is therefore denied.
|
|
171
|
-
|
|
170
|
+
# agents and prepares their execution context, never mutates a team. Any write
|
|
171
|
+
# permission falls through to the normal ReBAC check and is therefore denied.
|
|
172
|
+
#
|
|
173
|
+
# CAN_USE_TEAM_AGENTS (2026-07-20, AGENT-EVALUATION-RFC.md §8.6): added after an
|
|
174
|
+
# AUTHZ-05 review moved `POST .../agent-instances/{id}/prepare-execution` from
|
|
175
|
+
# CAN_READ to CAN_USE_TEAM_AGENTS (real prompt content leak, product/api.py) without
|
|
176
|
+
# updating this allowlist to match — the evaluation worker's M2M identity, which
|
|
177
|
+
# structurally never holds a team relation, was then unconditionally denied on every
|
|
178
|
+
# run case. Safe to include: CAN_USE_TEAM_AGENTS gates read/prepare only, never a
|
|
179
|
+
# mutation, and the leak concern that moved prepare-execution off CAN_READ
|
|
180
|
+
# (`context_prompt_text`) never applies to the worker's calls — they never pass
|
|
181
|
+
# `session_id`, the only way that field is populated.
|
|
182
|
+
SERVICE_AGENT_ALLOWED_TEAM_PERMISSIONS = frozenset(
|
|
183
|
+
{TeamPermission.CAN_READ, TeamPermission.CAN_USE_TEAM_AGENTS}
|
|
184
|
+
)
|
|
172
185
|
|
|
173
186
|
|
|
174
187
|
class AgentPermission(str, Enum):
|
|
@@ -346,6 +359,7 @@ class RebacEngine(ABC):
|
|
|
346
359
|
- Save `team thales owner tag cir`.
|
|
347
360
|
Returns a backend-specific consistency token when available.
|
|
348
361
|
"""
|
|
362
|
+
self._reject_unsanctioned_personal_team_write(relation)
|
|
349
363
|
token = await self._persist_relation(relation)
|
|
350
364
|
if self.enabled:
|
|
351
365
|
emit_audit_log(
|
|
@@ -357,6 +371,48 @@ class RebacEngine(ABC):
|
|
|
357
371
|
)
|
|
358
372
|
return token
|
|
359
373
|
|
|
374
|
+
@staticmethod
|
|
375
|
+
def _reject_unsanctioned_personal_team_write(relation: Relation) -> None:
|
|
376
|
+
"""Enforce the personal-team tuple invariant at the one audited write chokepoint.
|
|
377
|
+
|
|
378
|
+
A personal team (`personal-<uid>`) is a real ReBAC team object (AUTHZ-08),
|
|
379
|
+
but the *only* tuples ever allowed to name one are:
|
|
380
|
+
- the owner's own `team_editor` grant, self-healed by
|
|
381
|
+
`_ensure_personal_team_editor` on first touch;
|
|
382
|
+
- the structural `organization -> team` edge written by
|
|
383
|
+
`ensure_team_organization_relations` (same shape used for every team).
|
|
384
|
+
|
|
385
|
+
Every other shape — a different user, an elevated role, another relation
|
|
386
|
+
type — is refused here, unconditionally, regardless of caller. This is
|
|
387
|
+
what makes writing a real tuple for personal teams safe: no admin API,
|
|
388
|
+
import/export path, or future caller can ever grant (or be tricked into
|
|
389
|
+
granting) access to someone else's personal space, because there is
|
|
390
|
+
exactly one write chokepoint (`add_relation`) and it only recognizes
|
|
391
|
+
these two shapes for personal-team resources.
|
|
392
|
+
"""
|
|
393
|
+
if relation.resource.type != Resource.TEAM or not is_personal_team_id(
|
|
394
|
+
relation.resource.id
|
|
395
|
+
):
|
|
396
|
+
return
|
|
397
|
+
is_owner_editor_grant = (
|
|
398
|
+
relation.subject.type == Resource.USER
|
|
399
|
+
and relation.relation == RelationType.TEAM_EDITOR
|
|
400
|
+
and relation.resource.id == personal_team_id(relation.subject.id)
|
|
401
|
+
)
|
|
402
|
+
is_organization_edge = (
|
|
403
|
+
relation.subject.type == Resource.ORGANIZATION
|
|
404
|
+
and relation.subject.id == ORGANIZATION_ID
|
|
405
|
+
and relation.relation == RelationType.ORGANIZATION
|
|
406
|
+
)
|
|
407
|
+
if is_owner_editor_grant or is_organization_edge:
|
|
408
|
+
return
|
|
409
|
+
raise ValueError(
|
|
410
|
+
f"Refusing to write {relation.relation.value} for "
|
|
411
|
+
f"{relation.subject.type.value}:{relation.subject.id} onto personal "
|
|
412
|
+
f"team {relation.resource.id!r} — only the space's own owner may hold "
|
|
413
|
+
f"team_editor there, and it is provisioned automatically."
|
|
414
|
+
)
|
|
415
|
+
|
|
360
416
|
@abstractmethod
|
|
361
417
|
async def _persist_relation(self, relation: Relation) -> str | None:
|
|
362
418
|
"""Backend-specific write for one relation.
|
|
@@ -598,10 +654,21 @@ class RebacEngine(ABC):
|
|
|
598
654
|
consistency_token: str | None = None,
|
|
599
655
|
) -> list[RebacReference] | RebacDisabledResult:
|
|
600
656
|
"""List resources a user can access for one permission."""
|
|
657
|
+
resource_type = _resource_for_permission(permission)
|
|
658
|
+
# Self-heal the caller's own personal-team tuple before enumerating
|
|
659
|
+
# (AUTHZ-08 follow-up): `lookup_resources`/OpenFGA `ListObjects` never
|
|
660
|
+
# goes through `has_user_permission`/`check_user_team_permission_or_raise`,
|
|
661
|
+
# so without this a first-touch user's own personal team was silently
|
|
662
|
+
# missing from "list my teams" results (e.g. the `/fs` virtual `/teams`
|
|
663
|
+
# directory) until some other check-triggering call happened to
|
|
664
|
+
# provision it first.
|
|
665
|
+
await self._ensure_personal_team_editor(
|
|
666
|
+
user, resource_type, personal_team_id(user.uid)
|
|
667
|
+
)
|
|
601
668
|
return await self.lookup_resources(
|
|
602
669
|
subject=RebacReference(Resource.USER, user.uid),
|
|
603
670
|
permission=permission,
|
|
604
|
-
resource_type=
|
|
671
|
+
resource_type=resource_type,
|
|
605
672
|
consistency_token=consistency_token,
|
|
606
673
|
)
|
|
607
674
|
|
|
@@ -617,6 +684,38 @@ class RebacEngine(ABC):
|
|
|
617
684
|
) -> bool:
|
|
618
685
|
"""Return `True` when a subject is authorized for an action."""
|
|
619
686
|
|
|
687
|
+
async def _ensure_personal_team_editor(
|
|
688
|
+
self, user: KeycloakUser, resource_type: Resource, resource_id: str
|
|
689
|
+
) -> None:
|
|
690
|
+
"""Self-heal a personal team's own `team_editor` tuple on first touch (AUTHZ-08).
|
|
691
|
+
|
|
692
|
+
Personal teams (`personal-<uid>`) are real ReBAC team objects, but carry
|
|
693
|
+
no tuple until the owner's first permission check touches one — this
|
|
694
|
+
lazily provisions exactly one (`user:<uid> team_editor team:personal-
|
|
695
|
+
<uid>`), matching `build_personal_team`'s hardcoded permission set
|
|
696
|
+
(`can_read`, `can_update_resources`, `can_update_agents` — all implied
|
|
697
|
+
by `team_editor`). Runs before every `check_user_permission_or_raise`/
|
|
698
|
+
`has_user_permission` call so it applies uniformly across every backend,
|
|
699
|
+
with no per-caller special-casing.
|
|
700
|
+
|
|
701
|
+
Never provisions for another user's personal team — `add_relation`'s own
|
|
702
|
+
write-guard would refuse that shape regardless, but this check avoids
|
|
703
|
+
even attempting (and audit-logging) a doomed write on every such call.
|
|
704
|
+
"""
|
|
705
|
+
if not self.enabled or resource_type != Resource.TEAM:
|
|
706
|
+
return
|
|
707
|
+
if not is_personal_team_id(resource_id):
|
|
708
|
+
return
|
|
709
|
+
if resource_id != personal_team_id(user.uid):
|
|
710
|
+
return
|
|
711
|
+
team_ref = RebacReference(Resource.TEAM, resource_id)
|
|
712
|
+
user_ref = RebacReference(Resource.USER, user.uid)
|
|
713
|
+
if await self.has_direct_relation(user_ref, RelationType.TEAM_EDITOR, team_ref):
|
|
714
|
+
return
|
|
715
|
+
await self.add_user_relation(
|
|
716
|
+
user, RelationType.TEAM_EDITOR, Resource.TEAM, resource_id
|
|
717
|
+
)
|
|
718
|
+
|
|
620
719
|
async def has_user_permission(
|
|
621
720
|
self,
|
|
622
721
|
user: KeycloakUser,
|
|
@@ -627,6 +726,7 @@ class RebacEngine(ABC):
|
|
|
627
726
|
) -> bool:
|
|
628
727
|
"""Check one permission for one user/resource pair."""
|
|
629
728
|
resource_type = _resource_for_permission(permission)
|
|
729
|
+
await self._ensure_personal_team_editor(user, resource_type, resource_id)
|
|
630
730
|
return await self.has_permission(
|
|
631
731
|
RebacReference(Resource.USER, user.uid),
|
|
632
732
|
permission,
|
|
@@ -680,6 +780,7 @@ class RebacEngine(ABC):
|
|
|
680
780
|
) -> None:
|
|
681
781
|
"""User-focused wrapper around `check_permission_or_raise`."""
|
|
682
782
|
resource_type = _resource_for_permission(permission)
|
|
783
|
+
await self._ensure_personal_team_editor(user, resource_type, resource_id)
|
|
683
784
|
await self.check_permission_or_raise(
|
|
684
785
|
RebacReference(Resource.USER, user.uid),
|
|
685
786
|
permission,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
15
|
from fred_core.store.base_content_store import ContentStore
|
|
16
|
+
from fred_core.store.gcs_content_store import GcsContentStore
|
|
16
17
|
from fred_core.store.local_content_store import LocalContentStore
|
|
17
18
|
from fred_core.store.minio_content_store import MinioContentStore
|
|
18
19
|
from fred_core.store.opensearch_mapping_validator import (
|
|
@@ -23,6 +24,7 @@ from fred_core.store.vector_search import VectorSearchHit
|
|
|
23
24
|
|
|
24
25
|
__all__ = [
|
|
25
26
|
"ContentStore",
|
|
27
|
+
"GcsContentStore",
|
|
26
28
|
"LocalContentStore",
|
|
27
29
|
"MappingValidationError",
|
|
28
30
|
"MinioContentStore",
|