]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/smb: add RGW credentials resource and store in priv_stor 69103/head
authorAvan Thakkar <athakkar@redhat.com>
Thu, 11 Jun 2026 09:55:33 +0000 (15:25 +0530)
committerShweta Sodani <Shweta.Sodani@ibm.com>
Thu, 25 Jun 2026 16:57:52 +0000 (22:27 +0530)
Refactored RGW credential management to use dedicated RGWCredential
resources instead of embedding credentials directly in Share resources.
This will help to reuse the same credential across multiple shares using
same user.

RGW credentials must not appear in the public RADOS store, which any
client with pool caps can read. Instead, write a config:merge stub
containing only the credential fields to the private mon config-key
store, and pass its URI to the container alongside the primary config
URI via extra_config_uris.

- Public config: ceph_rgw:access_key / secret_access_key are empty strings
- Private stub: stored under smb/config/<cluster_id>/config.smb.rgw,
  contains only the real credential values via config:merge
- extra_config_uris: new Config field carrying supplementary URIs
  appended to SAMBACC_CONFIG after the primary config_uri

Signed-off-by: Avan Thakkar <athakkar@redhat.com>
Signed-off-by: Shweta Sodani <ssodani@redhat.com>
src/cephadm/cephadmlib/daemons/smb.py
src/pybind/mgr/cephadm/services/smb.py
src/pybind/mgr/smb/enums.py
src/pybind/mgr/smb/external.py
src/pybind/mgr/smb/handler.py
src/pybind/mgr/smb/internal.py
src/pybind/mgr/smb/module.py
src/pybind/mgr/smb/resources.py
src/pybind/mgr/smb/rgw_auth.py [new file with mode: 0644]
src/pybind/mgr/smb/sqlite_store.py
src/pybind/mgr/smb/staging.py

index 3026d6530b7d468e0b89162d4bc63f9fc7995ae5..684cbbe495f0ffa0530ed212112aab53211038e1 100644 (file)
@@ -245,6 +245,7 @@ class Config:
     debug_delay: int = 0
     join_sources: List[str] = dataclasses.field(default_factory=list)
     user_sources: List[str] = dataclasses.field(default_factory=list)
+    extra_config_uris: List[str] = dataclasses.field(default_factory=list)
     custom_dns: List[str] = dataclasses.field(default_factory=list)
     smb_port: int = 0
     ctdb_port: int = 0
@@ -267,6 +268,7 @@ class Config:
 
     def config_uris(self) -> List[str]:
         uris = [self.source_config]
+        uris.extend(self.extra_config_uris or [])
         uris.extend(self.user_sources or [])
         if self.clustered:
             # When clustered, we inject certain clustering related config vars
@@ -702,6 +704,7 @@ class SMB(ContainerDaemonForm):
         source_config = configs.get('config_uri', '')
         join_sources = configs.get('join_sources', [])
         user_sources = configs.get('user_sources', [])
+        extra_config_uris = configs.get('extra_config_uris', [])
         custom_dns = configs.get('custom_dns', [])
         instance_features = configs.get('features', [])
         files = data_utils.dict_get(configs, 'files', {})
@@ -768,6 +771,7 @@ class SMB(ContainerDaemonForm):
             source_config=source_config,
             join_sources=join_sources,
             user_sources=user_sources,
+            extra_config_uris=extra_config_uris,
             custom_dns=custom_dns,
             # major features
             domain_member=Features.DOMAIN.value in instance_features,
index d6f31c8312aa9e361a7243d649a891bfd8b4e9ed..f63730221ebd7ff61ea95f3d73b6068179929dd2 100644 (file)
@@ -206,6 +206,16 @@ class SMBService(CephService):
             ca_cert=ssl_params.ssl_ca_cert or '',
         )
 
+    def _rgw_creds_uri(self, cluster_id: str) -> Optional[str]:
+        from smb.external import rgw_config_key as _smb_rgw_config_key
+        from smb.mon_store import MonKeyConfigStore
+        _rgw_entry = MonKeyConfigStore(self.mgr)[
+            _smb_rgw_config_key(cluster_id)
+        ]
+        if _rgw_entry.exists():
+            return _rgw_entry.uri
+        return None
+
     def generate_config(
         self, daemon_spec: CephadmDaemonDeploySpec
     ) -> Tuple[Dict[str, Any], List[str]]:
@@ -220,6 +230,14 @@ class SMBService(CephService):
         config_blobs['cluster_id'] = smb_spec.cluster_id
         config_blobs['features'] = smb_spec.features
         config_blobs['config_uri'] = smb_spec.config_uri
+        # For RGW clusters, append the private-store config as an extra URI
+        # loaded after the public config.  sambacc's config:merge is a general
+        # merge mechanism; here the mgr populates it with only the RGW
+        # credential fields, keeping the public config the primary source of
+        # truth.
+        rgw_creds_uri = self._rgw_creds_uri(smb_spec.cluster_id)
+        if rgw_creds_uri:
+            config_blobs['extra_config_uris'] = [rgw_creds_uri]
         _add_cfg(config_blobs, 'join_sources', smb_spec.join_sources)
         _add_cfg(config_blobs, 'user_sources', smb_spec.user_sources)
         _add_cfg(config_blobs, 'custom_dns', smb_spec.custom_dns)
@@ -228,6 +246,7 @@ class SMBService(CephService):
         cluster_public_addrs = smb_spec.strict_cluster_ip_specs()
         _add_cfg(config_blobs, 'cluster_public_addrs', cluster_public_addrs)
         ceph_users = smb_spec.include_ceph_users or []
+
         config_blobs.update(
             self._ceph_config_and_keyring_for(
                 smb_spec, daemon_spec.daemon_id, ceph_users
index fac7ba54bbfd4a269a38641623c660d6c6b07f02..5b1850c9128e9e55564d0ab45863990fe2354931 100644 (file)
@@ -80,6 +80,7 @@ class ConfigNS(_StrEnum):
     USERS_AND_GROUPS = 'users_and_groups'
     JOIN_AUTHS = 'join_auths'
     TLS_CREDENTIALS = 'tls_creds'
+    RGW_CREDENTIALS = 'rgw_creds'
     EXTERNAL_CEPH_CLUSTERS = 'ext_ceph_clusters'
 
 
index 8937411e28d49fafbcbb36c2751290812b37e848..3eb5bb6b8ab1af7721bfce22ec5b607836bdf8fb 100644 (file)
@@ -49,9 +49,9 @@ def spec_backup_key(cluster_id: str) -> EntryKey:
     return (cluster_id, 'spec.smb')
 
 
-def rgw_credentials_key(cluster_id: str, share_id: str) -> EntryKey:
-    """Return key identifying RGW credentials for a share in an external store."""
-    return (cluster_id, f'rgw-creds.{share_id}.json')
+def rgw_config_key(cluster_id: str) -> EntryKey:
+    """Return key for the RGW credential stub in the private store."""
+    return (cluster_id, 'config.smb.rgw')
 
 
 def tls_credential_key(
index dc72afa880df76b36f3dbf1e4cbd9408ab9e5a25..a6270243ba92a8a8b226cfef6f6841d0813d70f4 100644 (file)
@@ -45,6 +45,7 @@ from .internal import (
     CommonResourceEntry,
     ExternalCephClusterEntry,
     JoinAuthEntry,
+    RGWCredentialEntry,
     ShareEntry,
     TLSCredentialEntry,
     UsersAndGroupsEntry,
@@ -55,6 +56,7 @@ from .proto import (
     ConfigEntry,
     ConfigStore,
     EarmarkResolver,
+    MonCommandIssuer,
     OrchSubmitter,
     PathResolver,
     Self,
@@ -62,11 +64,13 @@ from .proto import (
 )
 from .resources import SMBResource
 from .results import ErrorResult, Result, ResultGroup
+from .rgw_auth import RGWAuthorizer
 from .staging import (
     Staging,
     auth_refs,
     cross_check_resource,
     ext_cluster_refs,
+    rgw_credential_refs,
     tls_refs,
     ug_refs,
 )
@@ -99,6 +103,7 @@ class ClusterChangeGroup:
         join_auths: List[resources.JoinAuth],
         users_and_groups: List[resources.UsersAndGroups],
         tls_credentials: List[resources.TLSCredential],
+        rgw_credentials: List[resources.RGWCredential],
         ext_ceph_clusters: List[resources.ExternalCephCluster],
     ):
         self.cluster = cluster
@@ -106,6 +111,7 @@ class ClusterChangeGroup:
         self.join_auths = join_auths
         self.users_and_groups = users_and_groups
         self.tls_credentials = tls_credentials
+        self.rgw_credentials = rgw_credentials
         self.ext_ceph_clusters = ext_ceph_clusters
         # a cache for modified entries
         self.cache = config_store.EntryCache()
@@ -180,6 +186,15 @@ class _FakeAuthorizer:
         pass
 
 
+class _FakeMonCommandIssuer:
+    """A stub MonCommandIssuer for unit testing."""
+
+    def mon_command(
+        self, cmd_dict: dict, inbuf: Optional[str] = None
+    ) -> Tuple[int, str, str]:
+        return (0, '', '')
+
+
 class _Matcher:
     _match_resources = (
         resources.Cluster,
@@ -187,6 +202,7 @@ class _Matcher:
         resources.JoinAuth,
         resources.UsersAndGroups,
         resources.TLSCredential,
+        resources.RGWCredential,
         resources.ExternalCephCluster,
     )
 
@@ -316,6 +332,7 @@ class ClusterConfigHandler:
         priv_store: ConfigStore,
         path_resolver: Optional[PathResolver] = None,
         authorizer: Optional[AccessAuthorizer] = None,
+        mon_cmd_issuer: Optional[MonCommandIssuer] = None,
         orch: Optional[OrchSubmitter] = None,
         earmark_resolver: Optional[EarmarkResolver] = None,
         tool_execer: 'rgw.ToolExecer',
@@ -329,6 +346,9 @@ class ClusterConfigHandler:
         if authorizer is None:
             authorizer = _FakeAuthorizer()
         self._authorizer: AccessAuthorizer = authorizer
+        if mon_cmd_issuer is None:
+            mon_cmd_issuer = _FakeMonCommandIssuer()
+        self._mon_cmd_issuer: MonCommandIssuer = mon_cmd_issuer
         self._orch = orch  # if None, disables updating the spec via orch
         if earmark_resolver is None:
             earmark_resolver = cast(EarmarkResolver, _FakeEarmarkResolver())
@@ -405,6 +425,9 @@ class ClusterConfigHandler:
     def user_and_group_ids(self) -> List[str]:
         return list(UsersAndGroupsEntry.ids(self.internal_store))
 
+    def rgw_credential_ids(self) -> List[str]:
+        return list(RGWCredentialEntry.ids(self.internal_store))
+
     def all_resources(self) -> List[SMBResource]:
         with _store_transaction(self.internal_store):
             return self._search_resources(_Matcher())
@@ -536,13 +559,14 @@ class ClusterConfigHandler:
                 removed_cluster_ids.add(cluster_id)
                 continue
             present_cluster_ids.add(cluster_id)
+            cluster_shares = [
+                self._share_entry(cid, shid).get_share()
+                for cid, shid in share_ids
+                if cid == cluster_id
+            ]
             change_group = ClusterChangeGroup(
                 cluster,
-                [
-                    self._share_entry(cid, shid).get_share()
-                    for cid, shid in share_ids
-                    if cid == cluster_id
-                ],
+                cluster_shares,
                 [
                     self._join_auth_entry(_id).get_join_auth()
                     for _id in auth_refs(cluster)
@@ -557,6 +581,12 @@ class ClusterConfigHandler:
                     ).get_tls_credential()
                     for _id in tls_refs(cluster)
                 ],
+                [
+                    RGWCredentialEntry.from_store(
+                        self.internal_store, _id
+                    ).get_rgw_credential()
+                    for _id in rgw_credential_refs(cluster_shares)
+                ],
                 [
                     ExternalCephClusterEntry.from_store(
                         self.internal_store, _id
@@ -597,6 +627,7 @@ class ClusterConfigHandler:
         chg_join_ids: Set[str] = set()
         chg_ug_ids: Set[str] = set()
         chg_tls_ids: Set[str] = set()
+        chg_rgw_cred_ids: Set[str] = set()
         chg_extc_ids: Set[str] = set()
         for result in updated:
             state = (result.status or {}).get('state', None)
@@ -618,13 +649,21 @@ class ClusterConfigHandler:
                 chg_ug_ids.add(result.src.users_groups_id)
             elif isinstance(result.src, resources.TLSCredential):
                 chg_tls_ids.add(result.src.tls_credential_id)
+            elif isinstance(result.src, resources.RGWCredential):
+                chg_rgw_cred_ids.add(result.src.rgw_credential_id)
             elif isinstance(result.src, resources.ExternalCephCluster):
                 chg_extc_ids.add(result.src.external_ceph_cluster_id)
 
         # TODO: here's a lazy bit. if any join auths or users/groups changed we
         # will regen all clusters because these can be shared by >1 cluster.
         # In future, make this only pick clusters using the named resources.
-        if chg_join_ids or chg_ug_ids or chg_tls_ids or chg_extc_ids:
+        if (
+            chg_join_ids
+            or chg_ug_ids
+            or chg_tls_ids
+            or chg_extc_ids
+            or chg_rgw_cred_ids
+        ):
             chg_cluster_ids.update(ClusterEntry.ids(self.internal_store))
         return chg_cluster_ids
 
@@ -661,25 +700,16 @@ class ClusterConfigHandler:
         _save_pending_join_auths(self.priv_store, change_group)
         _save_pending_users_and_groups(self.priv_store, change_group)
         _save_pending_tls_credentials(self.priv_store, change_group)
-        _save_pending_rgw_credentials(self.priv_store, change_group)
-        rgw_credential_entries = {
-            share.share_id: change_group.cache[
-                external.rgw_credentials_key(
-                    change_group.cluster.cluster_id, share.share_id
-                )
-            ]
-            for share in change_group.shares
-            if share.rgw is not None
-            and share.rgw.access_key_id
-            and share.rgw.secret_access_key
-        }
+        # Create RGW authorizer for this cluster configuration
+        rgw_authorizer = RGWAuthorizer(self._mon_cmd_issuer)
         cluster_conf = _ClusterConf.assemble(
             change_group,
             self._path_resolver,
             self._authorizer,
-            rgw_credential_entries,
+            rgw_authorizer,
         )
         _save_pending_config(self.public_store, cluster_conf)
+        _save_pending_rgw_config(self.priv_store, cluster_conf)
         # remove any stray objects
         external.rm_other_in_ns(
             self.priv_store,
@@ -716,17 +746,6 @@ class ClusterConfigHandler:
             ]
             for tc in change_group.tls_credentials
         }
-        rgw_credential_entries = {
-            share.share_id: change_group.cache[
-                external.rgw_credentials_key(
-                    cluster.cluster_id, share.share_id
-                )
-            ]
-            for share in change_group.shares
-            if share.rgw is not None
-            and share.rgw.access_key_id
-            and share.rgw.secret_access_key
-        }
         ext_ceph_cluster = None
         if change_group.ext_ceph_clusters:
             assert len(change_group.ext_ceph_clusters) == 1
@@ -738,7 +757,7 @@ class ClusterConfigHandler:
             join_source_entries=join_source_entries,
             user_source_entries=user_source_entries,
             tls_credential_entries=tls_credential_entries,
-            data_entity=cluster_conf.data_entity,
+            user_entities=cluster_conf.user_entities,
             needs_proxy=_has_proxied_vfs(change_group),
             ext_ceph_cluster=ext_ceph_cluster,
             ssl_certificates=ssl_certificates,
@@ -830,8 +849,7 @@ class _ShareConf:
     resolver: PathResolver
     cephx_entity: str
     ceph_cluster: str
-    rgw_access_key_uri: Optional[str] = None
-    rgw_secret_key_uri: Optional[str] = None
+    rgw_entity: str = ''
 
 
 @dataclasses.dataclass(frozen=True)
@@ -839,7 +857,7 @@ class _ClusterConf:
     resource: resources.Cluster
     shares: Iterable[_ShareConf]
     change_group: ClusterChangeGroup
-    data_entity: str
+    user_entities: List[str]
 
     @classmethod
     def assemble(
@@ -847,7 +865,7 @@ class _ClusterConf:
         change_group: ClusterChangeGroup,
         default_resolver: PathResolver,
         authorizer: AccessAuthorizer,
-        rgw_credential_entries: Dict[str, ConfigEntry],
+        rgw_authorizer: RGWAuthorizer,
     ) -> Self:
         extcc = None
         assert isinstance(change_group.cluster, resources.Cluster)
@@ -857,102 +875,99 @@ class _ClusterConf:
 
         resolver = default_resolver
         cephx_entity = ''  # default to no entity for data access
-        cephadm_data_entity = ''  # passed to cephadm service spec
+        cephfs_entity = ''  # CephFS-specific entity
+        rgw_entity = ''  # RGW-specific entity
+        cephadm_data_entities: List[
+            str
+        ] = []  # passed to cephadm service spec
         ceph_cluster = ''  # empty string means local cluster
         if extcc:
             log.debug('external ceph cluster')
             resolver = ExoResolver(change_group.cluster)
             ceph_cluster = 'exo'
             cephx_entity = checked(extcc.cluster).cephfs_user.name
+            cephfs_entity = cephx_entity
+            cephadm_data_entities = [cephx_entity]
         elif change_group.shares:
             # Check if we have any CephFS shares that need CephX auth
             has_cephfs_shares = any(
                 s.cephfs is not None for s in change_group.shares
             )
+            has_rgw_shares = any(
+                s.rgw is not None for s in change_group.shares
+            )
             if has_cephfs_shares:
                 log.debug('local ceph cluster with CephFS shares')
-                cephx_entity = _cephx_data_entity(change_group.cluster)
+                cephfs_entity = _cephx_data_entity(change_group.cluster)
                 # ensure an entity exists with access to the volumes (CephFS only)
                 for share in change_group.shares:
                     if share.cephfs:
                         authorizer.authorize_entity(
-                            share.checked_cephfs.volume, cephx_entity
+                            share.checked_cephfs.volume, cephfs_entity
                         )
-                cephadm_data_entity = cephx_entity
-            else:
-                log.debug(
-                    'local ceph cluster with RGW shares only (no CephX auth needed)'
-                )
+                cephx_entity = cephfs_entity
+                cephadm_data_entities.append(cephfs_entity)
+            if has_rgw_shares:
+                log.debug('local ceph cluster with RGW shares')
+                # RGW shares need a CephX entity for RADOS access
+                rgw_entity = _cephx_rgw_entity(change_group.cluster)
+                # Authorize the entity using the provided RGW authorizer
+                rgw_authorizer.authorize_entity(rgw_entity)
+                cephadm_data_entities.append(rgw_entity)
         else:
             log.debug('local cluster without shares: skipping ceph auth')
         return cls(
             change_group.cluster,
             [
-                _make_share_conf(
+                _ShareConf(
                     s,
                     change_group.cluster,
                     resolver,
-                    cephx_entity,
+                    cephfs_entity,
                     ceph_cluster,
-                    rgw_credential_entries,
+                    rgw_entity=rgw_entity,
                 )
                 for s in change_group.shares
             ],
             change_group,
-            cephadm_data_entity,
+            cephadm_data_entities,
         )
 
 
-def _make_share_conf(
-    s: resources.Share,
-    cluster: resources.Cluster,
-    resolver: PathResolver,
-    cephx_entity: str,
-    ceph_cluster: str,
-    rgw_credential_entries: Dict[str, ConfigEntry],
-) -> _ShareConf:
-    creds_entry = rgw_credential_entries.get(s.share_id)
-    # Get the URI from the ConfigEntry object, not from the data
-    access_key_uri = (
-        f'URI:{creds_entry.uri}:access_key_id' if creds_entry else None
-    )
-    secret_key_uri = (
-        f'URI:{creds_entry.uri}:secret_access_key' if creds_entry else None
-    )
-    return _ShareConf(
-        s,
-        cluster,
-        resolver,
-        cephx_entity,
-        ceph_cluster,
-        rgw_access_key_uri=access_key_uri,
-        rgw_secret_key_uri=secret_key_uri,
-    )
-
-
 def _generate_rgw_share(
     conf: _ShareConf,
+    cred_map: Dict[str, resources.RGWCredential],
 ) -> Dict[str, Dict[str, str]]:
     """Generate Samba configuration for an RGW-backed share."""
     share = conf.resource
     rgw = share.rgw
     assert rgw is not None, "RGW storage configuration missing"
 
-    # Use URI-based credential references (already formatted in _make_share_conf)
-    access_key_uri = conf.rgw_access_key_uri or ''
-    secret_key_uri = conf.rgw_secret_key_uri or ''
+    # Get user_id from credential (credential_ref is guaranteed by validation)
+    assert rgw.credential_ref is not None
+    cred = cred_map.get(rgw.credential_ref)
+    if cred is None:
+        log.error(
+            "share %r references missing RGW credential %r",
+            share.name,
+            rgw.credential_ref,
+        )
+        user_id = ""
+    else:
+        user_id = cred.user_id or ""
 
     cfg = {
         # smb.conf options
         'options': {
             'path': '/',
             'vfs objects': 'ceph_rgw',
-            'ceph_rgw:config_file': '/etc/ceph/ceph.conf',
-            'ceph_rgw:keyring_file': '/etc/ceph/ceph.client.admin.keyring',
             'ceph_rgw:bucket': rgw.bucket,
-            'ceph_rgw:user_id': rgw.user_id or '',
-            'ceph_rgw:access_key': access_key_uri,
-            'ceph_rgw:secret_access_key': secret_key_uri,
+            'ceph_rgw:user_id': user_id,
+            # Credential values are left empty here; they are injected at
+            # deploy time via a config:merge stub in the private store so
+            # they never appear in the public RADOS config.
+            'ceph_rgw:access_key': '',
+            'ceph_rgw:secret_access_key': '',
             'ceph_rgw:debug': 'off',
             'read only': ynbool(share.readonly),
             'browseable': ynbool(share.browseable),
@@ -977,12 +992,7 @@ def _generate_rgw_share(
     return cfg
 
 
-def _generate_share(conf: _ShareConf) -> Dict[str, Dict[str, str]]:
-    share = conf.resource
-    cephx_entity = conf.cephx_entity
-    cephfs = share.checked_cephfs
-    assert cephfs.provider.is_vfs(), "not a vfs provider"
-    assert cephx_entity, "cephx entity name missing"
+def cephx_stripped_entity(cephx_entity: str) -> str:
     # very annoyingly, samba's ceph module absolutely must NOT have the
     # "client." bit in front. JJM has been tripped up by this multiple times -
     # seemingly every time this module is touched.
@@ -990,6 +1000,16 @@ def _generate_share(conf: _ShareConf) -> Dict[str, Dict[str, str]]:
     plen = len(_prefix)
     if cephx_entity.startswith(_prefix):
         cephx_entity = cephx_entity[plen:]
+    return cephx_entity
+
+
+def _generate_share(conf: _ShareConf) -> Dict[str, Dict[str, str]]:
+    share = conf.resource
+    cephx_entity = conf.cephx_entity
+    cephfs = share.checked_cephfs
+    assert cephfs.provider.is_vfs(), "not a vfs provider"
+    assert cephx_entity, "cephx entity name missing"
+    cephx_entity = cephx_stripped_entity(cephx_entity)
     path = conf.resolver.resolve(
         cephfs.volume,
         cephfs.subvolumegroup,
@@ -1156,9 +1176,30 @@ def _generate_config(conf: _ClusterConf) -> Dict[str, Any]:
     cluster_global_opts['smb ports'] = str(_smb_port(cluster))
     _set_debug_level(cluster_global_opts, conf)
 
+    # Check if cluster has RGW shares and add global RGW options
+    has_rgw_shares = any(share.resource.rgw for share in conf.shares)
+    if has_rgw_shares:
+        # Get pre-calculated stripped RGW entity from first RGW share
+        rgw_entity = next(
+            (share.rgw_entity for share in conf.shares if share.resource.rgw),
+            '',
+        )
+        if rgw_entity:
+            cluster_global_opts[
+                'ceph_rgw:config_file'
+            ] = '/etc/ceph/ceph.conf'
+            cluster_global_opts['ceph_rgw:keyring_file'] = '/etc/ceph/keyring'
+            cluster_global_opts['ceph_rgw:id'] = cephx_stripped_entity(
+                rgw_entity
+            )
+
+    cred_map = {
+        c.rgw_credential_id: c for c in conf.change_group.rgw_credentials
+    }
+
     share_configs = {
         share.resource.name: (
-            _generate_rgw_share(share)
+            _generate_rgw_share(share, cred_map)
             if share.resource.rgw
             else _generate_share(share)
         )
@@ -1215,7 +1256,7 @@ def _generate_smb_service_spec(
     join_source_entries: List[ConfigEntry],
     user_source_entries: List[ConfigEntry],
     tls_credential_entries: Dict[str, ConfigEntry],
-    data_entity: str = '',
+    user_entities: List[str],
     needs_proxy: bool = False,
     ext_ceph_cluster: Optional[resources.ExternalCephCluster],
     ssl_certificates: Dict[str, SSLParameters],
@@ -1250,9 +1291,6 @@ def _generate_smb_service_spec(
     user_sources: List[str] = []
     for entry in user_source_entries:
         user_sources.append(entry.uri)
-    user_entities: Optional[List[str]] = None
-    if data_entity:
-        user_entities = [data_entity]
 
     rc_cert = rc_key = rc_ca_cert = None
     if cluster.is_feature_enabled(_REMOTE_CONTROL):
@@ -1462,31 +1500,6 @@ def _save_pending_tls_credentials(
         change_group.cache_updated_entry(tc_entry)
 
 
-def _save_pending_rgw_credentials(
-    store: ConfigStore,
-    change_group: ClusterChangeGroup,
-) -> None:
-    """Save RGW credentials for shares in the priv store."""
-    cluster = change_group.cluster
-    assert isinstance(cluster, resources.Cluster)
-
-    # Save credentials for each RGW share
-    for share in change_group.shares:
-        if share.rgw is not None:
-            # Only save if credentials are present
-            if share.rgw.access_key_id and share.rgw.secret_access_key:
-                ext_key = external.rgw_credentials_key(
-                    cluster.cluster_id, share.share_id
-                )
-                creds_entry = store[ext_key]
-                creds_data = {
-                    'access_key_id': share.rgw.access_key_id,
-                    'secret_access_key': share.rgw.secret_access_key,
-                }
-                creds_entry.set(creds_data)
-                change_group.cache_updated_entry(creds_entry)
-
-
 def _save_pending_config(
     store: ConfigStore,
     cluster_conf: _ClusterConf,
@@ -1498,6 +1511,49 @@ def _save_pending_config(
     cluster_conf.change_group.cache_updated_entry(centry)
 
 
+def _save_pending_rgw_config(
+    store: ConfigStore,
+    cluster_conf: _ClusterConf,
+) -> None:
+    """Save an RGW credential stub to the private store for RGW clusters.
+
+    Writes a stub using sambacc's config:merge mechanism, which merges the
+    provided JSON on top of the primary config at load time.  The mgr
+    populates the stub with only the RGW credential fields here; everything else
+    stays in the public RADOS config.  The stub URI is passed to the container
+    via extra_config_uris so credentials never appear in the public pool.
+    """
+    cluster_id = cluster_conf.resource.cluster_id
+    rgw_shares = [s for s in cluster_conf.shares if s.resource.rgw]
+    if not rgw_shares:
+        return
+    cred_map = {
+        c.rgw_credential_id: c
+        for c in cluster_conf.change_group.rgw_credentials
+    }
+    merge_shares: Dict[str, Any] = {}
+    for sc in rgw_shares:
+        rgw = sc.resource.rgw
+        assert rgw is not None
+        assert rgw.credential_ref is not None
+        cred = cred_map[rgw.credential_ref]
+        access_key = cred.access_key_id or ''
+        secret_key = cred.secret_access_key or ''
+        merge_shares[sc.resource.name] = {
+            'options': {
+                'ceph_rgw:access_key': access_key,
+                'ceph_rgw:secret_access_key': secret_key,
+            }
+        }
+    stub = {
+        'samba-container-config': 'v0',
+        'config:merge': {'shares': merge_shares},
+    }
+    centry = store[external.rgw_config_key(cluster_id)]
+    centry.set(stub)
+    cluster_conf.change_group.cache_updated_entry(centry)
+
+
 def _save_pending_spec_backup(
     store: ConfigStore, change_group: ClusterChangeGroup, smb_spec: SMBSpec
 ) -> None:
@@ -1515,6 +1571,15 @@ def _cephx_data_entity(cluster: resources.Cluster) -> str:
     return f'client.smb.fs.cluster.{cluster.cluster_id}'
 
 
+def _cephx_rgw_entity(cluster: resources.Cluster) -> str:
+    """Generate a name for the cephx key that a cluster (smbd) will
+    use for RGW data access.
+    """
+    if cluster.external_ceph_cluster:
+        return ''
+    return f'client.smb.rgw.cluster.{cluster.cluster_id}'
+
+
 @contextlib.contextmanager
 def _store_transaction(store: ConfigStore) -> Iterator[None]:
     transaction = getattr(store, 'transaction', None)
index a954ebc782dea7f351f8f0b68bc3c2d365d61ca3..8b16826ea253c38a6774c60dc8015ebe75650623 100644 (file)
@@ -223,6 +223,23 @@ class TLSCredentialEntry(CommonResourceEntry):
         return self.get_resource_type(resources.TLSCredential)
 
 
+class RGWCredentialEntry(CommonResourceEntry):
+    """RGWCredentialEntry resource getter/setter for the smb internal data
+    store(s).
+    """
+
+    namespace = ConfigNS.RGW_CREDENTIALS
+    _for_resource = resources.RGWCredential
+
+    @classmethod
+    def to_key(cls, resource: SMBResource) -> ResourceKey:
+        assert isinstance(resource, cls._for_resource)
+        return ResourceIDKey(resource.rgw_credential_id)
+
+    def get_rgw_credential(self) -> resources.RGWCredential:
+        return self.get_resource_type(resources.RGWCredential)
+
+
 class ExternalCephClusterEntry(CommonResourceEntry):
     """ExternalCephCluster resource getter/setter for internal store."""
 
@@ -251,6 +268,7 @@ def map_resource_entry(
         resources.JoinAuth: JoinAuthEntry,
         resources.UsersAndGroups: UsersAndGroupsEntry,
         resources.TLSCredential: TLSCredentialEntry,
+        resources.RGWCredential: RGWCredentialEntry,
         resources.ExternalCephCluster: ExternalCephClusterEntry,
     }
     try:
index 185d1b1718d84191886b6445bcd20672be67a7ec..5392a12621b37116bb0bc74e8444edc1744582b4 100644 (file)
@@ -100,6 +100,7 @@ class Module(orchestrator.OrchestratorClientMixin, MgrModule):
             public_store=self._public_store,
             path_resolver=path_resolver,
             authorizer=authorizer,
+            mon_cmd_issuer=self,
             orch=self._orch_backend(enable_orch=uo),
             earmark_resolver=earmark_resolver,
             tool_execer=self,
@@ -630,12 +631,12 @@ class Module(orchestrator.OrchestratorClientMixin, MgrModule):
         """Create an SMB share backed by RGW"""
         try:
             # Pass 'self' which conforms to ToolExecer protocol
-            (
-                fetched_user_id,
-                access_key,
-                secret_key,
-            ) = rgw.fetch_rgw_credentials(self, bucket, user_id)
+            fetched_user_id = rgw.fetch_rgw_credentials(
+                self, bucket, user_id
+            )[0]
 
+            # Create share with user credentials
+            # The staging layer will auto-create the credential if needed
             share = resources.Share(
                 cluster_id=cluster_id,
                 share_id=share_id,
@@ -644,11 +645,11 @@ class Module(orchestrator.OrchestratorClientMixin, MgrModule):
                 rgw=resources.RGWStorage(
                     bucket=bucket,
                     user_id=fetched_user_id,
-                    access_key_id=access_key,
-                    secret_access_key=secret_key,
                 ),
             )
-            return self._apply_res([share], create_only=True).one()
+
+            # Apply share resource (staging may create credential too)
+            return self._apply_res([share], create_only=True).squash(share)
         except ValueError as e:
             # Create a minimal share resource for error reporting
             error_share = resources.Share(
index 118a40fa813df674fc693e3bd8b8f029cc7cb939..66d2e7585f54adec0bb61460d438c15a353ee401 100644 (file)
@@ -406,8 +406,7 @@ class RGWStorage(_RBase):
 
     bucket: str
     user_id: Optional[str] = None
-    access_key_id: Optional[str] = None
-    secret_access_key: Optional[str] = None
+    credential_ref: Optional[str] = None
 
     def validate(self) -> None:
         if not self.bucket:
@@ -418,23 +417,12 @@ class RGWStorage(_RBase):
         return self.__class__(
             bucket=self.bucket,
             user_id=self.user_id,
-            access_key_id=(
-                _password_convert(self.access_key_id, operation)
-                if self.access_key_id
-                else None
-            ),
-            secret_access_key=(
-                _password_convert(self.secret_access_key, operation)
-                if self.secret_access_key
-                else None
-            ),
+            credential_ref=self.credential_ref,
         )
 
     @resourcelib.customize
     def _customize_resource(rc: resourcelib.Resource) -> resourcelib.Resource:
         rc.user_id.quiet = True
-        rc.access_key_id.quiet = True
-        rc.secret_access_key.quiet = True
         return rc
 
 
@@ -1199,6 +1187,52 @@ class TLSCredential(_RBase):
         return self
 
 
+@resourcelib.resource('ceph.smb.rgw.credential')
+class RGWCredential(_RBase):
+    """Contains RGW user credentials that can be referenced by multiple
+    SMB shares backed by RGW buckets.
+    """
+
+    rgw_credential_id: str
+    user_id: str
+    access_key_id: str
+    secret_access_key: str
+    intent: Intent = Intent.PRESENT
+    linked_to_cluster: Optional[str] = None
+
+    def validate(self) -> None:
+        if not self.rgw_credential_id:
+            raise ValueError('rgw_credential_id requires a value')
+        validation.check_id(self.rgw_credential_id)
+        if self.linked_to_cluster is not None:
+            validation.check_id(self.linked_to_cluster)
+        if self.intent is Intent.PRESENT:
+            if not self.user_id:
+                raise ValueError('user_id must be specified')
+            if not self.access_key_id:
+                raise ValueError('access_key_id must be specified')
+            if not self.secret_access_key:
+                raise ValueError('secret_access_key must be specified')
+
+    @resourcelib.customize
+    def _customize_resource(rc: resourcelib.Resource) -> resourcelib.Resource:
+        rc.linked_to_cluster.quiet = True
+        rc.on_construction_error(InvalidResourceError.wrap)
+        return rc
+
+    def convert(self, operation: ConversionOp) -> Self:
+        return self.__class__(
+            rgw_credential_id=self.rgw_credential_id,
+            intent=self.intent,
+            user_id=self.user_id,
+            access_key_id=_password_convert(self.access_key_id, operation),
+            secret_access_key=_password_convert(
+                self.secret_access_key, operation
+            ),
+            linked_to_cluster=self.linked_to_cluster,
+        )
+
+
 @resourcelib.component()
 class CephUserKey(_RBase):
     """A Ceph User Key name and value pair."""
@@ -1243,6 +1277,7 @@ SMBResource = Union[
     Share,
     UsersAndGroups,
     TLSCredential,
+    RGWCredential,
     ExternalCephCluster,
 ]
 
diff --git a/src/pybind/mgr/smb/rgw_auth.py b/src/pybind/mgr/smb/rgw_auth.py
new file mode 100644 (file)
index 0000000..14929f1
--- /dev/null
@@ -0,0 +1,50 @@
+"""RGW authorization utilities for SMB."""
+
+from typing import List, Optional
+
+import logging
+
+from .proto import MonCommandIssuer
+
+log = logging.getLogger(__name__)
+
+
+class RGWAuthorizationGrantError(ValueError):
+    pass
+
+
+class RGWAuthorizer:
+    """Using the rados APIs provided by the ceph mgr, authorize cephx users for
+    RGW access via RADOS.
+    """
+
+    def __init__(self, mc: MonCommandIssuer) -> None:
+        self._mc = mc
+
+    def authorize_entity(
+        self, entity: str, caps: Optional[List[str]] = None
+    ) -> None:
+        """Create or update a CephX entity with RGW access capabilities."""
+
+        assert entity.startswith('client.')
+
+        # Default caps for RGW access via RADOS
+        # These allow the SMB daemon to access RGW data through RADOS
+        if not caps:
+            caps = [
+                'mon',
+                'allow r',
+                'osd',
+                'allow rwx tag rgw *=*',
+            ]
+
+        cmd = {
+            'prefix': 'auth get-or-create',
+            'entity': entity,
+            'caps': caps,
+        }
+        log.info('Requesting RGW authorization: %r', cmd)
+        ret, _, status = self._mc.mon_command(cmd)
+        if ret != 0:
+            raise RGWAuthorizationGrantError(status)
+        log.info('RGW authorization request success: %r', status)
index 0f01aa5f5b005db3438fa8d7e2433403ff8c2fa9..39e2de03e5e1f1c340fee38a7a3d2091ff31fbe7 100644 (file)
@@ -535,6 +535,22 @@ class MirrorExternalCephCluster(Mirror):
         return filtered
 
 
+class MirrorRGWCredentials(Mirror):
+    """Mirroring configuration for objects in the rgw_credentials namespace."""
+
+    def __init__(self, store: ConfigStore) -> None:
+        super().__init__('rgw_credentials', store)
+
+    def filter_object(self, obj: Simplified) -> Simplified:
+        """Filter rgw_credential for sqlite3 store."""
+        filtered = copy.deepcopy(obj)
+        if filtered.get('access_key_id'):
+            filtered.pop('access_key_id', None)
+        if filtered.get('secret_access_key'):
+            filtered.pop('secret_access_key', None)
+        return filtered
+
+
 def _tables(
     *,
     specialize: bool = True,
@@ -556,6 +572,7 @@ def _tables(
         SimpleTable('join_auths', 'join_auths'),
         SimpleTable('users_and_groups', 'users_and_groups'),
         SimpleTable('tls_creds', 'tls_creds'),
+        SimpleTable('rgw_creds', 'rgw_creds'),
         SimpleTable('ext_ceph_clusters', 'ext_ceph_clusters'),
     ]
 
@@ -582,6 +599,10 @@ def _mirror_external_ceph_clusters(
     return (opts or {}).get('mirror_external_ceph_clusters') != 'no'
 
 
+def _mirror_rgw_credentials(opts: Optional[Dict[str, str]] = None) -> bool:
+    return (opts or {}).get('mirror_rgw_credentials') != 'no'
+
+
 def mgr_sqlite3_db(
     mgr: Any, opts: Optional[Dict[str, str]] = None
 ) -> SqliteStore:
@@ -611,6 +632,8 @@ def mgr_sqlite3_db_with_mirroring(
         mirrors.append(MirrorTLSCredentials(mirror_store))
     if _mirror_external_ceph_clusters(opts):
         mirrors.append(MirrorExternalCephCluster(mirror_store))
+    if _mirror_rgw_credentials(opts):
+        mirrors.append(MirrorRGWCredentials(mirror_store))
     return SqliteMirroringStore(mgr, tables, mirrors)
 
 
index aceb776cbfe1284542fec4356b430ab8278eac15..87f96890a66c474eaaa21c53619d5758de50bed5 100644 (file)
@@ -30,6 +30,7 @@ from .internal import (
     ClusterEntry,
     JoinAuthEntry,
     ResourceEntry,
+    RGWCredentialEntry,
     ShareEntry,
     TLSCredentialEntry,
     UsersAndGroupsEntry,
@@ -127,6 +128,18 @@ class Staging:
             self.destination_store, ug_id
         ).get_users_and_groups()
 
+    def get_rgw_credential(
+        self, rgw_credential_id: str
+    ) -> resources.RGWCredential:
+        ekey = (str(RGWCredentialEntry.namespace), rgw_credential_id)
+        if ekey in self.incoming:
+            res = self.incoming[ekey]
+            assert isinstance(res, resources.RGWCredential)
+            return res
+        return RGWCredentialEntry.from_store(
+            self.destination_store, rgw_credential_id
+        ).get_rgw_credential()
+
     def save(self) -> ResultGroup:
         results = ResultGroup()
         for res in self.deleted.values():
@@ -167,6 +180,7 @@ class Staging:
         self._prune(cids, JoinAuthEntry, resources.JoinAuth)
         self._prune(cids, UsersAndGroupsEntry, resources.UsersAndGroups)
         self._prune(cids, TLSCredentialEntry, resources.TLSCredential)
+        self._prune(cids, RGWCredentialEntry, resources.RGWCredential)
 
 
 def auth_refs(cluster: resources.Cluster) -> Collection[str]:
@@ -360,27 +374,12 @@ def _check_share_resource(
             status={"cluster_id": share.cluster_id},
         )
 
-    # Handle RGW shares - validate bucket existence and auto-fetch credentials
+    # Handle RGW shares
     if share.rgw is not None:
-        # Validate bucket exists
-        if not rgw.validate_rgw_bucket(
-            staging._tool_execer, share.rgw.bucket
-        ):
-            raise ErrorResult(
-                share,
-                msg=f"RGW bucket '{share.rgw.bucket}' does not exist or is not accessible",
-            )
-
-        # Auto-fetch credentials if not provided
-        if (
-            not share.rgw.user_id
-            or not share.rgw.access_key_id
-            or not share.rgw.secret_access_key
-        ):
+        # If credential_ref is not provided, auto-create credential
+        if not share.rgw.credential_ref:
+            # Fetch credentials from RGW
             try:
-                log.debug(
-                    f"Auto-fetching RGW credentials for bucket {share.rgw.bucket}"
-                )
                 (
                     fetched_user_id,
                     access_key,
@@ -390,21 +389,80 @@ def _check_share_resource(
                     share.rgw.bucket,
                     share.rgw.user_id or '',
                 )
-                # Update the share's RGW storage with fetched credentials
-                share.rgw = resources.RGWStorage(
-                    bucket=share.rgw.bucket,
+            except ValueError as e:
+                raise ErrorResult(
+                    share,
+                    msg=f"Failed to fetch RGW credentials: {str(e)}",
+                )
+
+            # Create credential resource automatically
+            # Use user_id as credential_id (linked to cluster via linked_to_cluster field)
+            credential_id = fetched_user_id
+
+            # Check if credential already exists
+            try:
+                cred = staging.get_rgw_credential(credential_id)
+                # Credential exists, validate it's linked to correct cluster
+                if (
+                    cred.linked_to_cluster
+                    and cred.linked_to_cluster != share.cluster_id
+                ):
+                    raise ErrorResult(
+                        share,
+                        msg='RGW credential is linked to a different cluster',
+                        status={
+                            'credential_ref': credential_id,
+                            'other_cluster_id': cred.linked_to_cluster,
+                        },
+                    )
+            except KeyError:
+                # Credential doesn't exist, create it
+                cred = resources.RGWCredential(
+                    rgw_credential_id=credential_id,
                     user_id=fetched_user_id,
                     access_key_id=access_key,
                     secret_access_key=secret_key,
+                    linked_to_cluster=share.cluster_id,
                 )
-                log.debug(
-                    f"Successfully fetched credentials for user {fetched_user_id}"
+                # Stage the credential
+                staging.stage(cred)
+
+            # Update share to use credential_ref
+            share.rgw = resources.RGWStorage(
+                bucket=share.rgw.bucket,
+                credential_ref=credential_id,
+            )
+        else:
+            # Validate existing credential_ref
+            try:
+                cred = staging.get_rgw_credential(share.rgw.credential_ref)
+            except KeyError:
+                raise ErrorResult(
+                    share,
+                    msg=f"RGW credential '{share.rgw.credential_ref}' not found",
+                    status={"credential_ref": share.rgw.credential_ref},
                 )
-            except ValueError as e:
+
+            if (
+                cred.linked_to_cluster
+                and cred.linked_to_cluster != share.cluster_id
+            ):
                 raise ErrorResult(
                     share,
-                    msg=f"Failed to fetch RGW credentials: {str(e)}",
+                    msg='RGW credential is linked to a different cluster',
+                    status={
+                        'credential_ref': share.rgw.credential_ref,
+                        'other_cluster_id': cred.linked_to_cluster,
+                    },
                 )
+        # Validate bucket exists
+        if not rgw.validate_rgw_bucket(
+            staging._tool_execer, share.rgw.bucket
+        ):
+            raise ErrorResult(
+                share,
+                msg=f"RGW bucket '{share.rgw.bucket}' does not exist or is not accessible",
+            )
 
         name_used_by = _share_name_in_use(staging, share)
         if name_used_by:
@@ -677,6 +735,59 @@ def _check_tls_credential_present(
             )
 
 
+@cross_check_resource.register
+def _check_rgw_credential_resource(
+    resource: resources.RGWCredential, staging: Staging, **_kw: Any
+) -> None:
+    """Check that the RGW credential resource can be updated."""
+    if resource.intent == Intent.PRESENT:
+        return _check_rgw_credential_present(resource, staging)
+    return _check_rgw_credential_removed(resource, staging)
+
+
+def _check_rgw_credential_removed(
+    rgw_cred: resources.RGWCredential, staging: Staging
+) -> None:
+    refs_in_use: Dict[str, List[str]] = {}
+    for cid, sid in ShareEntry.ids(staging):
+        try:
+            share = ShareEntry.from_store(
+                staging.destination_store, cid, sid
+            ).get_share()
+        except KeyError:
+            continue
+        if (
+            share.rgw
+            and share.rgw.credential_ref == rgw_cred.rgw_credential_id
+        ):
+            refs_in_use.setdefault(rgw_cred.rgw_credential_id, []).append(
+                f'{cid}.{sid}'
+            )
+    if rgw_cred.rgw_credential_id in refs_in_use:
+        raise ErrorResult(
+            rgw_cred,
+            msg='RGW credential resource in use by shares',
+            status={
+                'shares': refs_in_use[rgw_cred.rgw_credential_id],
+            },
+        )
+
+
+def _check_rgw_credential_present(
+    rgw_cred: resources.RGWCredential, staging: Staging
+) -> None:
+    if rgw_cred.linked_to_cluster:
+        cids = set(ClusterEntry.ids(staging))
+        if rgw_cred.linked_to_cluster not in cids:
+            raise ErrorResult(
+                rgw_cred,
+                msg='linked_to_cluster id not valid',
+                status={
+                    'unknown_id': rgw_cred.linked_to_cluster,
+                },
+            )
+
+
 @cross_check_resource.register
 def _check_external_ceph_cluster_resource(
     ext_cluster: resources.ExternalCephCluster, staging: Staging, **_: Any
@@ -753,6 +864,17 @@ def tls_refs(cluster: resources.Cluster) -> Collection[str]:
     return _remotectl_tls_refs(cluster) | _keybridge_tls_refs(cluster)
 
 
+def rgw_credential_refs(
+    shares: List[resources.Share],
+) -> Collection[str]:
+    """Return all credential_ref IDs used by RGW-backed shares."""
+    return {
+        share.rgw.credential_ref
+        for share in shares
+        if share.rgw is not None and share.rgw.credential_ref
+    }
+
+
 def _keybridge_ids(
     cluster: resources.Cluster,
 ) -> Dict[str, resources.KeyBridgeScopeIdentity]: