From: Avan Thakkar Date: Thu, 11 Jun 2026 09:55:33 +0000 (+0530) Subject: mgr/smb: add RGW credentials resource and store in priv_stor X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=8916aa85f73a6d3dabc62bfce4e029bff7e9322d;p=ceph.git mgr/smb: add RGW credentials resource and store in priv_stor 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//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 Signed-off-by: Shweta Sodani --- diff --git a/src/cephadm/cephadmlib/daemons/smb.py b/src/cephadm/cephadmlib/daemons/smb.py index 3026d6530b7..684cbbe495f 100644 --- a/src/cephadm/cephadmlib/daemons/smb.py +++ b/src/cephadm/cephadmlib/daemons/smb.py @@ -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, diff --git a/src/pybind/mgr/cephadm/services/smb.py b/src/pybind/mgr/cephadm/services/smb.py index d6f31c8312a..f63730221eb 100644 --- a/src/pybind/mgr/cephadm/services/smb.py +++ b/src/pybind/mgr/cephadm/services/smb.py @@ -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 diff --git a/src/pybind/mgr/smb/enums.py b/src/pybind/mgr/smb/enums.py index fac7ba54bbf..5b1850c9128 100644 --- a/src/pybind/mgr/smb/enums.py +++ b/src/pybind/mgr/smb/enums.py @@ -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' diff --git a/src/pybind/mgr/smb/external.py b/src/pybind/mgr/smb/external.py index 8937411e28d..3eb5bb6b8ab 100644 --- a/src/pybind/mgr/smb/external.py +++ b/src/pybind/mgr/smb/external.py @@ -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( diff --git a/src/pybind/mgr/smb/handler.py b/src/pybind/mgr/smb/handler.py index dc72afa880d..a6270243ba9 100644 --- a/src/pybind/mgr/smb/handler.py +++ b/src/pybind/mgr/smb/handler.py @@ -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) diff --git a/src/pybind/mgr/smb/internal.py b/src/pybind/mgr/smb/internal.py index a954ebc782d..8b16826ea25 100644 --- a/src/pybind/mgr/smb/internal.py +++ b/src/pybind/mgr/smb/internal.py @@ -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: diff --git a/src/pybind/mgr/smb/module.py b/src/pybind/mgr/smb/module.py index 185d1b1718d..5392a12621b 100644 --- a/src/pybind/mgr/smb/module.py +++ b/src/pybind/mgr/smb/module.py @@ -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( diff --git a/src/pybind/mgr/smb/resources.py b/src/pybind/mgr/smb/resources.py index 118a40fa813..66d2e7585f5 100644 --- a/src/pybind/mgr/smb/resources.py +++ b/src/pybind/mgr/smb/resources.py @@ -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 index 00000000000..14929f17765 --- /dev/null +++ b/src/pybind/mgr/smb/rgw_auth.py @@ -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) diff --git a/src/pybind/mgr/smb/sqlite_store.py b/src/pybind/mgr/smb/sqlite_store.py index 0f01aa5f5b0..39e2de03e5e 100644 --- a/src/pybind/mgr/smb/sqlite_store.py +++ b/src/pybind/mgr/smb/sqlite_store.py @@ -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) diff --git a/src/pybind/mgr/smb/staging.py b/src/pybind/mgr/smb/staging.py index aceb776cbfe..87f96890a66 100644 --- a/src/pybind/mgr/smb/staging.py +++ b/src/pybind/mgr/smb/staging.py @@ -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]: