From 717bdca9485fb0ff19fc6a1c0a10e6eb18fdb95a Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Fri, 16 Feb 2024 17:04:48 +0100 Subject: [PATCH] WIP --- .../HsOfficeCoopAssetsTransactionEntity.java | 9 +- .../office/partner/HsOfficePartnerEntity.java | 17 +++- .../rbacgrant/RbacGrantsMermaidService.java | 85 +++++++++++----- .../resources/db/changelog/050-rbac-base.sql | 37 +++++-- .../db/changelog/054-rbac-context.sql | 7 +- .../changelog/233-hs-office-partner-rbac.sql | 96 ++++++++++++------- ...fficePartnerRepositoryIntegrationTest.java | 11 +-- 7 files changed, 180 insertions(+), 82 deletions(-) diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java index 087ca158..0b579a85 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/coopassets/HsOfficeCoopAssetsTransactionEntity.java @@ -12,6 +12,7 @@ import org.hibernate.annotations.GenericGenerator; import jakarta.persistence.*; import java.math.BigDecimal; import java.time.LocalDate; +import java.util.Optional; import java.util.UUID; import static java.util.Optional.ofNullable; @@ -28,7 +29,7 @@ import static net.hostsharing.hsadminng.stringify.Stringify.stringify; public class HsOfficeCoopAssetsTransactionEntity implements Stringifyable, HasUuid { private static Stringify stringify = stringify(HsOfficeCoopAssetsTransactionEntity.class) - .withProp(HsOfficeCoopAssetsTransactionEntity::getMemberNumber) + .withIdProp(HsOfficeCoopAssetsTransactionEntity::getTaggedMemberNumber) .withProp(HsOfficeCoopAssetsTransactionEntity::getValueDate) .withProp(HsOfficeCoopAssetsTransactionEntity::getTransactionType) .withProp(HsOfficeCoopAssetsTransactionEntity::getAssetValue) @@ -75,8 +76,8 @@ public class HsOfficeCoopAssetsTransactionEntity implements Stringifyable, HasUu private String comment; - public Integer getMemberNumber() { - return ofNullable(membership).map(HsOfficeMembershipEntity::getMemberNumber).orElse(null); + public String getTaggedMemberNumber() { + return ofNullable(membership).map(HsOfficeMembershipEntity::toShortString).orElse("M-?????"); } @Override @@ -86,6 +87,6 @@ public class HsOfficeCoopAssetsTransactionEntity implements Stringifyable, HasUu @Override public String toShortString() { - return "%s%+1.2f".formatted(getMemberNumber(), assetValue); + return "%s:%+1.2f".formatted(getTaggedMemberNumber(), Optional.ofNullable(assetValue).orElse(BigDecimal.ZERO)); } } diff --git a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java index 6815d9f8..229b81f9 100644 --- a/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java +++ b/src/main/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerEntity.java @@ -1,6 +1,10 @@ package net.hostsharing.hsadminng.hs.office.partner; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; import net.hostsharing.hsadminng.errors.DisplayName; import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactEntity; import net.hostsharing.hsadminng.hs.office.person.HsOfficePersonEntity; @@ -11,7 +15,14 @@ import net.hostsharing.hsadminng.stringify.Stringifyable; import org.hibernate.annotations.NotFound; import org.hibernate.annotations.NotFoundAction; -import jakarta.persistence.*; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import java.util.UUID; import static java.util.Optional.ofNullable; @@ -48,7 +59,7 @@ public class HsOfficePartnerEntity implements Stringifyable, HasUuid { @Column(name = "partnernumber", columnDefinition = "numeric(5) not null") private Integer partnerNumber; - @ManyToOne + @ManyToOne(cascade = CascadeType.ALL) @JoinColumn(name = "partnerroleuuid", nullable = false) private HsOfficeRelationshipEntity partnerRole; diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsMermaidService.java b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsMermaidService.java index 914ba43a..58d6e0eb 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsMermaidService.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/rbacgrant/RbacGrantsMermaidService.java @@ -13,9 +13,11 @@ import java.io.IOException; import java.util.*; import java.util.stream.Stream; +import static java.util.stream.Collectors.groupingBy; import static java.util.stream.Collectors.joining; import static net.hostsharing.hsadminng.rbac.rbacgrant.RbacGrantsMermaidService.Include.*; +// TODO: cleanup - this code was 'hacked' to quickly fix a specific problem, needs refactoring @Service public class RbacGrantsMermaidService { @@ -102,17 +104,24 @@ public class RbacGrantsMermaidService { } private String toMermaidFlowchart(final HashSet graph) { - return "%%{init:{'flowchart':{'htmlLabels':false}}}%%\n" + + final var entities = graph.stream() + .flatMap(g -> Stream.of( + new Node(g.getAscendantIdName(), g.getAscendingUuid()), + new Node(g.getDescendantIdName(), g.getDescendantUuid())) + ) + .collect(groupingBy(RbacGrantsMermaidService::entityIdName)); + + return "%%{init:{'flowchart':{'htmlLabels':false}}}%%\n\n" + "flowchart TB\n\n" - + graph.stream() - .flatMap(g -> Stream.of( - new Node(g.getAscendantIdName(), g.getAscendingUuid()), - new Node(g.getAscendantIdName(), g.getAscendingUuid())) - ) - .map(n -> node(n.idName(), n.uuid())) - .sorted() - .collect(joining("\n\n")) - + "\n" + + entities.entrySet().stream() + .map(entity -> "subgraph " + quoted(entity.getKey()) + subgraphDisplay(entity.getKey()) + "\n\n " + + entity.getValue().stream() + .map(n -> node(n.idName(), n.uuid()).replace("\n", "\n ")) + .sorted() + .distinct() + .collect(joining("\n\n "))) + .collect(joining("\n\nend\n\n")) + + "\n\nend\n\n" + graph.stream() .map(g -> quoted(g.getAscendantIdName()) + (g.isAssumed() ? " --> " : " -.-> ") + @@ -121,37 +130,61 @@ public class RbacGrantsMermaidService { .collect(joining("\n")); } - private String node(final String idName, final UUID uuid) { - return quoted(idName) + display(idName, uuid); + private String subgraphDisplay(final String entityId) { + // this does not work according to Mermaid bug https://github.com/mermaid-js/mermaid/issues/3806 + // if (entityId.contains("#")) { + // final var parts = entityId.split("#"); + // final var table = parts[0]; + // final var entity = parts[1]; + // if (table.equals("entity")) { + // return "[" + entity "]"; + // } + // return "[" + table + "\n" + entity + "]"; + // } + return "[" + entityId + "]"; } - private String display(final String idName, final UUID uuid) { - // role hs_office_relationship#FirstGmbH-with-REPRESENTATIVE-FirbySusan.admin - // TODO: refactor by separate algorithms for perm/role/user - final var refType = idName.split(" ", 2)[0]; + private static String entityIdName(final Node node) { + final var refType = refType(node.idName()); + if (refType.equals("user")) { + return "users"; + } + if (refType.equals("perm")) { + return node.idName().split(" ", 4)[3]; + } + if (refType.equals("role")) { + final var withoutRolePrefix = node.idName().substring("role:".length()); + return withoutRolePrefix.substring(0, withoutRolePrefix.lastIndexOf('.')); + } + throw new IllegalArgumentException("unknown refType '" + refType + "' in '" + node.idName() + "'"); + } + + private String node(final String idName, final UUID uuid) { + return quoted(idName) + nodeContent(idName, uuid); + } + + private String nodeContent(final String idName, final UUID uuid) { + final var refType = refType(idName); if (refType.equals("user")) { final var displayName = idName.substring(refType.length()+1); - return "(" + displayName + "\n" + uuid + ")"; + return "(" + displayName + "\nref:" + uuid + ")"; } if (refType.equals("role")) { final var roleType = idName.substring(idName.lastIndexOf('.') + 1); - final var objectName = idName.substring(refType.length()+1, idName.length() - roleType.length() - 1); - final var tableName = objectName.contains("#") ? objectName.split("#")[0] : ""; - final var instanceName = objectName.contains("#") ? objectName.split("#", 2)[1] : objectName; - final var displayName = (tableName.equals("global") ? "" : tableName) + "\n" + instanceName + "\n" + uuid + "\n" + roleType; - return "[" + displayName + "]"; + return "[" + roleType + "\nref:" + uuid + "]"; } if (refType.equals("perm")) { final var roleType = idName.split(" ")[1]; - final var objectName = idName.split(" ")[3]; - final var tableName = objectName.contains("#") ? objectName.split("#")[0] : ""; - final var instanceName = objectName.contains("#") ? objectName.split("#", 2)[1] : objectName; - final var displayName = "\n" + tableName + "\n" + instanceName + "\n" + uuid + (roleType == null ? "" : ("\n" + roleType));return "{{" + displayName + "}}"; + return "{{" + roleType + "\nref:" + uuid + "}}"; } return ""; } + private static String refType(final String idName) { + return idName.split(" ", 2)[0]; + } + @NotNull private static String quoted(final String idName) { return idName.replace(" ", ":").replaceAll("@.*", ""); diff --git a/src/main/resources/db/changelog/050-rbac-base.sql b/src/main/resources/db/changelog/050-rbac-base.sql index 14f92c7c..bf6c37d7 100644 --- a/src/main/resources/db/changelog/050-rbac-base.sql +++ b/src/main/resources/db/changelog/050-rbac-base.sql @@ -680,28 +680,47 @@ begin if (isGranted(superRoleId, subRoleId)) then delete from RbacGrants where ascendantUuid = superRoleId and descendantUuid = subRoleId; else - raise exception 'cannot revoke role % (%) from % (% because it is not granted', + raise exception 'cannot revoke role % (%) from % (%) because it is not granted', subRole, subRoleId, superRole, superRoleId; end if; end; $$; -create or replace procedure revokePermissionFromRole(permission RbacRoleDescriptor, superRole RbacRoleDescriptor) +create or replace procedure revokePermissionFromRole(permissionId UUID, superRole RbacRoleDescriptor) language plpgsql as $$ declare superRoleId uuid; - subRoleId uuid; + permissionOp text; + objectTable text; + objectUuid uuid; begin superRoleId := findRoleId(superRole); - subRoleId := findRoleId(subRole); perform assertReferenceType('superRoleId (ascendant)', superRoleId, 'RbacRole'); - perform assertReferenceType('subRoleId (descendant)', subRoleId, 'RbacRole'); - if (isGranted(superRoleId, subRoleId)) then - delete from RbacGrants where ascendantUuid = superRoleId and descendantUuid = subRoleId; + if (isGranted(superRoleId, permissionId)) then + delete from RbacGrants where ascendantUuid = superRoleId and descendantUuid = permissionId; else - raise exception 'cannot revoke role % (%) from % (% because it is not granted', - subRole, subRoleId, superRole, superRoleId; + +-- FOR grantUuid IN SELECT grantUuid FROM rbacGrants where ascendantUuid=superRoleId LOOP +-- select p.op, o.objectTable, o.uuid +-- from rbacGrants g +-- join rbacPermission p on p.uuid=g.descendantUuid +-- join rbacobject o on o.uuid=p.objectUuid +-- where g.uuid= +-- into permissionOp, objectTable, objectUuid; +-- RAISE NOTICE 'col1: %, col2: %', quote_ident(items.col1), quote_ident(items.col2); +-- END LOOP; + + + select p.op, o.objectTable, o.uuid + from rbacGrants g + join rbacPermission p on p.uuid=g.descendantUuid + join rbacobject o on o.uuid=p.objectUuid + where g.uuid=permissionId + into permissionOp, objectTable, objectUuid; + + raise exception 'cannot revoke permission % on %#% (%) from % (%) because it is not granted', + permissionOp, objectTable, objectUuid, permissionId, superRole, superRoleId; end if; end; $$; diff --git a/src/main/resources/db/changelog/054-rbac-context.sql b/src/main/resources/db/changelog/054-rbac-context.sql index 6b26bb50..b5b554e5 100644 --- a/src/main/resources/db/changelog/054-rbac-context.sql +++ b/src/main/resources/db/changelog/054-rbac-context.sql @@ -56,14 +56,17 @@ begin roleTypeToAssume = split_part(roleNameParts, '#', 3); objectUuidToAssume = findObjectUuidByIdName(objectTableToAssume, objectNameToAssume); + if objectUuidToAssume is null then + raise exception '[401] object % cannot be found in table %', objectNameToAssume, objectTableToAssume; + end if; - select uuid as roleuuidToAssume + select uuid from RbacRole r where r.objectUuid = objectUuidToAssume and r.roleType = roleTypeToAssume into roleUuidToAssume; if roleUuidToAssume is null then - raise exception '[403] role % not accessible for user %', roleName, currentSubjects(); + raise exception '[403] role % does not exist or is not accessible for user %', roleName, currentUser(); end if; if not isGranted(currentUserUuid, roleUuidToAssume) then raise exception '[403] user % has no permission to assume role %', currentUser(), roleName; diff --git a/src/main/resources/db/changelog/233-hs-office-partner-rbac.sql b/src/main/resources/db/changelog/233-hs-office-partner-rbac.sql index 58dfed8c..6245371c 100644 --- a/src/main/resources/db/changelog/233-hs-office-partner-rbac.sql +++ b/src/main/resources/db/changelog/233-hs-office-partner-rbac.sql @@ -20,98 +20,130 @@ create or replace function hsOfficePartnerRbacRolesTrigger() language plpgsql strict as $$ declare - oldPartnerRole hs_office_relationship; - newPartnerRole hs_office_relationship; + partnerUuid uuid default new.uuid; + partnerDetailsUuid uuid default new.detailsUuid; + oldPartnerRel hs_office_relationship; + newPartnerRel hs_office_relationship; begin call enterTriggerForObjectUuid(NEW.uuid); - select * from hs_office_relationship as r where r.uuid = NEW.partnerroleuuid into newPartnerRole; + select * from hs_office_relationship as r where r.uuid = NEW.partnerroleuuid into newPartnerRel; if TG_OP = 'INSERT' then -- Permissions and Grants for Partner call grantPermissionsToRole( - getRoleId(hsOfficeRelationshipOwner(newPartnerRole), 'fail'), - createPermissions(NEW.uuid, array ['*']) + getRoleId(hsOfficeRelationshipOwner(newPartnerRel), 'fail'), + createPermissions(partnerUuid, array ['*']) ); call grantPermissionsToRole( - getRoleId(hsOfficeRelationshipAdmin(newPartnerRole), 'fail'), - createPermissions(NEW.uuid, array ['edit']) + getRoleId(hsOfficeRelationshipAdmin(newPartnerRel), 'fail'), + createPermissions(partnerUuid, array ['edit']) ); call grantPermissionsToRole( - getRoleId(hsOfficeRelationshipTenant(newPartnerRole), 'fail'), - createPermissions(NEW.uuid, array ['view']) + getRoleId(hsOfficeRelationshipTenant(newPartnerRel), 'fail'), + createPermissions(partnerUuid, array ['view']) ); -- Permissions and Grants for PartnerDetails call grantPermissionsToRole( - getRoleId(hsOfficeRelationshipOwner(newPartnerRole), 'fail'), - createPermissions(NEW.detailsUuid, array ['*']) + getRoleId(hsOfficeRelationshipOwner(newPartnerRel), 'fail'), + createPermissions(partnerDetailsUuid, array ['*']) ); call grantPermissionsToRole( - getRoleId(hsOfficeRelationshipAdmin(newPartnerRole), 'fail'), - createPermissions(NEW.detailsUuid, array ['edit']) + getRoleId(hsOfficeRelationshipAdmin(newPartnerRel), 'fail'), + createPermissions(partnerDetailsUuid, array ['edit']) ); call grantPermissionsToRole( -- Yes, here hsOfficePartnerAGENT is used, not hsOfficeRelationshipTENANT. -- Do NOT grant view permission on partner-details to hsOfficeRelationshipTENANT! -- Otherwise package-admins etc. would be able to read the data. - getRoleId(hsOfficeRelationshipAgent(newPartnerRole), 'fail'), - createPermissions(NEW.detailsUuid, array ['view']) + getRoleId(hsOfficeRelationshipAgent(newPartnerRel), 'fail'), + createPermissions(partnerDetailsUuid, array ['view']) ); elsif TG_OP = 'UPDATE' then if OLD.partnerRoleUuid <> NEW.partnerRoleUuid then - select * from hs_office_relationship as r where r.uuid = OLD.partnerRoleUuid into oldPartnerRole; + select * from hs_office_relationship as r where r.uuid = OLD.partnerRoleUuid into oldPartnerRel; - -- Revoke all Permissions from old partner relationship - -- TODO: introduce call revokeAllPermissionsOnDescendantFromAllRolesOfAscendant(OLD, oldPartnerRole); - delete from rbacGrants where descendantUuid==OLD.uuid and ascendantUuid==OLD.partnerRoleUuid; + -- Revokes from Partner + + call revokePermissionFromRole( + findPermissionId(partnerUuid, 'view'), + hsOfficeRelationshipTenant(oldPartnerRel) + ); + +-- call revokePermissionFromRole( +-- findPermissionId(partnerUuid, 'edit'), +-- hsOfficeRelationshipAdmin(oldPartnerRel) +-- ); +-- +-- call revokePermissionFromRole( +-- findPermissionId(partnerUuid, '*'), +-- hsOfficeRelationshipOwner(oldPartnerRel) +-- ); -- Grants for Partner call grantPermissionsToRole( - getRoleId(hsOfficeRelationshipOwner(newPartnerRole), 'fail'), - array[findPermissionId(NEW.uuid, '*')] + getRoleId(hsOfficeRelationshipOwner(newPartnerRel), 'fail'), + array[findPermissionId(partnerUuid, '*')] ); call grantPermissionsToRole( - getRoleId(hsOfficeRelationshipAdmin(newPartnerRole), 'fail'), - array[findPermissionId(NEW.uuid, 'edit')] + getRoleId(hsOfficeRelationshipAdmin(newPartnerRel), 'fail'), + array[findPermissionId(partnerUuid, 'edit')] ); call grantPermissionsToRole( - getRoleId(hsOfficeRelationshipTenant(newPartnerRole), 'fail'), - array[findPermissionId(NEW.uuid, 'view')] + getRoleId(hsOfficeRelationshipTenant(newPartnerRel), 'fail'), + array[findPermissionId(partnerUuid, 'view')] ); + -- Revokes from PartnerDetails + +-- call revokePermissionFromRole( +-- findPermissionId(partnerDetailsUuid, 'view'), +-- hsOfficeRelationshipAgent(oldPartnerRel) +-- ); +-- +-- call revokePermissionFromRole( +-- findPermissionId(partnerDetailsUuid, 'edit'), +-- hsOfficeRelationshipAdmin(oldPartnerRel) +-- ); +-- +-- call revokePermissionFromRole( +-- findPermissionId(partnerDetailsUuid, '*'), +-- hsOfficeRelationshipOwner(oldPartnerRel) +-- ); + -- Grants for PartnerDetails call grantPermissionsToRole( - getRoleId(hsOfficeRelationshipOwner(newPartnerRole), 'fail'), - array[findPermissionId(NEW.detailsUuid, '*')] + getRoleId(hsOfficeRelationshipOwner(newPartnerRel), 'fail'), + array[findPermissionId(partnerDetailsUuid, '*')] ); call grantPermissionsToRole( - getRoleId(hsOfficeRelationshipAdmin(newPartnerRole), 'fail'), - array[findPermissionId(NEW.detailsUuid, array ['edit'])] + getRoleId(hsOfficeRelationshipAdmin(newPartnerRel), 'fail'), + array[findPermissionId(partnerDetailsUuid, 'edit')] ); call grantPermissionsToRole( -- Yes, here hsOfficePartnerAGENT is used, not hsOfficePartnerTENANT. -- Do NOT grant view permission on partner-details to hsOfficeRelationshipTENANT! -- Otherwise package-admins etc. would be able to read the data. - getRoleId(hsOfficeRelationshipAgent(newPartnerRole), 'fail'), - array[findPermissionId(NEW.detailsUuid, 'view')] + getRoleId(hsOfficeRelationshipAgent(newPartnerRel), 'fail'), + array[findPermissionId(partnerDetailsUuid, 'view')] ); end if; @@ -120,7 +152,7 @@ begin raise exception 'invalid usage of TRIGGER'; end if; - call leaveTriggerForObjectUuid(NEW.uuid); + call leaveTriggerForObjectUuid(partnerUuid); return NEW; end; $$; diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java index 1f985e8f..5a52a97d 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/partner/HsOfficePartnerRepositoryIntegrationTest.java @@ -263,12 +263,12 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTestWithClean givenPartner, "hs_office_person#ErbenBesslerMelBessler.admin"); assertThatPartnerActuallyInDatabase(givenPartner); - - // when - RbacGrantsMermaidService.writeToFile("givenPartner with partner Erben Bessler + fifth contact", + RbacGrantsMermaidService.writeToFile("initial partner: Erben Bessler + fifth contact", mermaidService.allGrantsFrom(givenPartner.getUuid(), "view", EnumSet.of(Include.USERS)), "doc/all-grants-before-update.md"); + + // when final var result = jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net"); givenPartner.setPartnerRole(givenSomeTemporaryHostsharingPartnerRole("Third OHG", "sixth contact")); @@ -277,10 +277,9 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTestWithClean // then result.assertSuccessful(); - RbacGrantsMermaidService.writeToFile("givenPartner with partner to Third OHG + sixth contact", + RbacGrantsMermaidService.writeToFile("updated partner: Third OHG + sixth contact", mermaidService.allGrantsFrom(result.returnedValue().getUuid(), "view", EnumSet.of(Include.USERS)), "doc/all-grants-after-update.md"); - assertThatPartnerIsVisibleForUserWithRole( result.returnedValue(), "global#global.admin"); @@ -335,7 +334,7 @@ class HsOfficePartnerRepositoryIntegrationTest extends ContextBasedTestWithClean final String assumedRoles) { jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net", assumedRoles); - RbacGrantsMermaidService.writeToFile("givenPartner within assertThatPartnerIsNotVisibleForUserWithRole", + RbacGrantsMermaidService.writeToFile("partner visible in assertThatPartnerIsNotVisibleForUserWithRole", mermaidService.allGrantsFrom(entity.getUuid(), "view", EnumSet.of(Include.USERS)), "doc/all-grants-within-assertThatPartnerIsNotVisibleForUserWithRole.md");