diff --git a/infra/mongodb/docker-entrypoint-initdb.d/init.js b/infra/mongodb/docker-entrypoint-initdb.d/init.js index 3579f200b..00a7199df 100644 --- a/infra/mongodb/docker-entrypoint-initdb.d/init.js +++ b/infra/mongodb/docker-entrypoint-initdb.d/init.js @@ -96,37 +96,56 @@ db.Policies.insertOne( statements: [ { _id: getUUIDString(), - resourceType: "general", + resourceType: "account", + effect: "allow", + actions: ["UpdateOrgName"], + resources: ["account/*"] + }, + { + _id: getUUIDString(), + resourceType: "iam", effect: "allow", actions: ["CanManageIAM"], - resources: ["iam"] + resources: ["iam/*"] }, { _id: getUUIDString(), - resourceType: "general", + resourceType: "access-token", effect: "allow", - actions: ["UpdateOrgName"], - resources: ["account"] + actions: [ + "ManageServiceAccessTokens", + "ManagePersonalAccessTokens", + "ListAccessTokens" + ], + resources: ["access-token/*"] }, { _id: getUUIDString(), - resourceType: "general", + resourceType: "project", effect: "allow", actions: [ "ListProjects", "CreateProject", "DeleteProject", - "AccessEnvs", "UpdateProjectSettings", "ListEnvs", - "CreateEnv", + "CreateEnv" + ], + resources: ["project/*"] + }, + { + _id: getUUIDString(), + resourceType: "env", + effect: "allow", + actions: [ + "AccessEnvs", "DeleteEnv", "UpdateEnvSettings", "CreateEnvSecret", "DeleteEnvSecret", "UpdateEnvSecret" ], - resources: ["project"] + resources: ["project/*:env/*"] } ], createdAt: new Date(), @@ -143,14 +162,33 @@ db.Policies.insertOne( statements: [ { _id: getUUIDString(), - resourceType: "general", + resourceType: "access-token", + effect: "allow", + actions: [ + "ManageServiceAccessTokens", + "ManagePersonalAccessTokens", + "ListAccessTokens" + ], + resources: ["access-token/*"] + }, + { + _id: getUUIDString(), + resourceType: "project", effect: "allow", actions: [ - "AccessEnvs", "ListProjects", "ListEnvs" ], - resources: ["project"] + resources: ["project/*"] + }, + { + _id: getUUIDString(), + resourceType: "env", + effect: "allow", + actions: [ + "AccessEnvs" + ], + resources: ["project/*:env/*"] } ], createdAt: new Date(), diff --git a/modules/back-end/src/Api/Controllers/AccessTokenController.cs b/modules/back-end/src/Api/Controllers/AccessTokenController.cs new file mode 100644 index 000000000..e0fce8d55 --- /dev/null +++ b/modules/back-end/src/Api/Controllers/AccessTokenController.cs @@ -0,0 +1,79 @@ +using Application.AccessTokens; +using Application.Bases.Models; + +namespace Api.Controllers; + +[Route("api/v{version:apiVersion}/organizations/{organizationId:guid}/access-tokens")] +public class AccessTokenController : ApiControllerBase +{ + [HttpGet] + public async Task>> GetListAsync( + Guid organizationId, + [FromQuery] AccessTokenFilter filter) + { + var request = new GetAccessTokenList + { + OrganizationId = organizationId, + Filter = filter + }; + + var accessTokens = await Mediator.Send(request); + return Ok(accessTokens); + } + + [HttpGet("is-name-used")] + public async Task> IsNameUsedAsync(Guid organizationId, string name) + { + var request = new IsAccessTokenNameUsed + { + OrganizationId = organizationId, + Name = name + }; + + var isNameUsed = await Mediator.Send(request); + return Ok(isNameUsed); + } + + [HttpPost] + public async Task> CreateAsync(Guid organizationId, CreateAccessToken request) + { + request.OrganizationId = organizationId; + + var accessToken = await Mediator.Send(request); + return Ok(accessToken); + } + + [HttpPut("{id:guid}/toggle")] + public async Task> ToggleAsync(Guid id) + { + var request = new ToggleAccessTokenStatus + { + Id = id + }; + + var success = await Mediator.Send(request); + return Ok(success); + } + + [HttpDelete("{id:guid}")] + public async Task> DeleteAsync(Guid id) + { + var request = new DeleteAccessToken + { + Id = id + }; + + var success = await Mediator.Send(request); + return Ok(success); + } + + [HttpPut("{id:guid}")] + public async Task> UpdateAsync(Guid id, UpdateAccessToken request) + { + request.Id = id; + + var accessTokenVm = await Mediator.Send(request); + + return Ok(accessTokenVm); + } +} \ No newline at end of file diff --git a/modules/back-end/src/Application/AccessTokens/AccessTokenFilter.cs b/modules/back-end/src/Application/AccessTokens/AccessTokenFilter.cs new file mode 100644 index 000000000..d01b6980d --- /dev/null +++ b/modules/back-end/src/Application/AccessTokens/AccessTokenFilter.cs @@ -0,0 +1,12 @@ +using Application.Bases.Models; + +namespace Application.AccessTokens; + +public class AccessTokenFilter : PagedRequest +{ + public string Name { get; set; } + + public Guid? CreatorId { get; set; } + + public string Type { get; set; } +} \ No newline at end of file diff --git a/modules/back-end/src/Application/AccessTokens/AccessTokenVm.cs b/modules/back-end/src/Application/AccessTokens/AccessTokenVm.cs new file mode 100644 index 000000000..94505a311 --- /dev/null +++ b/modules/back-end/src/Application/AccessTokens/AccessTokenVm.cs @@ -0,0 +1,23 @@ +using Application.Users; +using Domain.Policies; + +namespace Application.AccessTokens; + +public class AccessTokenVm +{ + public Guid Id { get; set; } + + public string Name { get; set; } + + public string Type { get; set; } + + public string Status { get; set; } + + public string Token { get; set; } + + public IEnumerable Permissions { get; set; } + + public DateTime? LastUsedAt { get; set; } + + public UserVm Creator { get; set; } +} \ No newline at end of file diff --git a/modules/back-end/src/Application/AccessTokens/CreateAccessToken.cs b/modules/back-end/src/Application/AccessTokens/CreateAccessToken.cs new file mode 100644 index 000000000..44fd539f8 --- /dev/null +++ b/modules/back-end/src/Application/AccessTokens/CreateAccessToken.cs @@ -0,0 +1,116 @@ +using System.Text.RegularExpressions; +using Application.Bases; +using Application.Bases.Exceptions; +using Application.Users; +using Domain.AccessTokens; +using Domain.Policies; + +namespace Application.AccessTokens; + +public class CreateAccessToken : IRequest +{ + public Guid OrganizationId { get; set; } + + public string Name { get; set; } + + public string Type { get; set; } + + public IEnumerable Permissions { get; set; } +} + +public class CreateAccessTokenValidator : AbstractValidator +{ + public CreateAccessTokenValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithErrorCode(ErrorCodes.NameIsRequired); + + RuleFor(x => x.Type) + .Must(AccessTokenTypes.IsDefined).WithErrorCode(ErrorCodes.InvalidAccessTokenType); + + RuleFor(x => x.Permissions) + .Must(permissions => permissions.Any()) + .Unless(x => x.Type == AccessTokenTypes.Personal) + .WithErrorCode(ErrorCodes.ServiceAccessTokenMustDefinePolicies); + } +} + +public class CreateAccessTokenHandler : IRequestHandler +{ + private readonly IMemberService _memberService; + private readonly ICurrentUser _currentUser; + private readonly IAccessTokenService _service; + private readonly IMapper _mapper; + + public CreateAccessTokenHandler( + IAccessTokenService service, + IMemberService memberService, + ICurrentUser currentUser, + IMapper mapper) + { + _service = service; + _currentUser = currentUser; + _memberService = memberService; + _mapper = mapper; + } + + public async Task Handle(CreateAccessToken request, CancellationToken cancellationToken) + { + if (request.Type == AccessTokenTypes.Service) + { + var authorizedPolices = + await _memberService.GetPoliciesAsync(request.OrganizationId, _currentUser.Id); + + var haveUnauthorizedPermissions = request.Permissions.Any(x => + { + var matchedPolicy = authorizedPolices.FirstOrDefault(policy => + policy.Statements.Any(statement => + (statement.ResourceType == x.ResourceType || statement.ResourceType == "*") && + statement.Effect == "allow" && + x.Resources.All(rsc => statement.Resources.Any(resource => MatchRule(rsc, resource))) && + x.Actions.All(act => statement.Actions.Any(action => MatchRule(act, action)))) + ); + + return matchedPolicy == null; + }); + + if (haveUnauthorizedPermissions) + { + throw new BusinessException(ErrorCodes.Forbidden); + } + } + + var existed = + await _service.FindOneAsync(at => string.Equals(at.Name, request.Name, StringComparison.OrdinalIgnoreCase)); + if (existed != null) + { + throw new BusinessException(ErrorCodes.EntityExistsAlready); + } + + var accessToken = + new AccessToken(request.OrganizationId, _currentUser.Id, request.Name, request.Type, request.Permissions); + + await _service.AddOneAsync(accessToken); + + return _mapper.Map(accessToken); + } + + // use "*" (star) as a wildcard for example: + // "a*b" => everything that starts with "a" and ends with "b" + // "a*" => everything that starts with "a" + // "*b" => everything that ends with "b" + // "*a*" => everything that has an "a" in it + // "*a*b*"=> everything that has an "a" in it, followed by anything, followed by a "b", followed by anything + private static bool MatchRule(string str, string rule) + { + string EscapeRegex(string s) => Regex.Replace(s, "([.*+?^=!:${}()|\\[\\]\\\\/])", "\\$1"); + + var matchPattern = rule + .Split('*') + .Select(EscapeRegex) + .Aggregate((x, y) => $"{x}.*{y}"); + + var regex = new Regex($"^{matchPattern}$"); + return regex.IsMatch(str); + } +} \ No newline at end of file diff --git a/modules/back-end/src/Application/AccessTokens/DeleteAccessToken.cs b/modules/back-end/src/Application/AccessTokens/DeleteAccessToken.cs new file mode 100644 index 000000000..808d27cb9 --- /dev/null +++ b/modules/back-end/src/Application/AccessTokens/DeleteAccessToken.cs @@ -0,0 +1,23 @@ +namespace Application.AccessTokens; + +public class DeleteAccessToken : IRequest +{ + public Guid Id { get; set; } +} + +public class DeleteAccessTokenHandler : IRequestHandler +{ + private readonly IAccessTokenService _service; + + public DeleteAccessTokenHandler(IAccessTokenService service) + { + _service = service; + } + + public async Task Handle(DeleteAccessToken request, CancellationToken cancellationToken) + { + await _service.DeleteAsync(request.Id); + + return true; + } +} \ No newline at end of file diff --git a/modules/back-end/src/Application/AccessTokens/GetAccessTokenList.cs b/modules/back-end/src/Application/AccessTokens/GetAccessTokenList.cs new file mode 100644 index 000000000..8dd4fb77f --- /dev/null +++ b/modules/back-end/src/Application/AccessTokens/GetAccessTokenList.cs @@ -0,0 +1,44 @@ +using Application.Bases.Models; +using Application.Users; + +namespace Application.AccessTokens; + +public class GetAccessTokenList : IRequest> +{ + public Guid OrganizationId { get; set; } + + public AccessTokenFilter Filter { get; set; } +} + +public class GetAccessTokenListHandler : IRequestHandler> +{ + private readonly IAccessTokenService _accessTokenService; + private readonly IUserService _userService; + private readonly IMapper _mapper; + + public GetAccessTokenListHandler(IAccessTokenService accessTokenService, IUserService userService, IMapper mapper) + { + _accessTokenService = accessTokenService; + _userService = userService; + _mapper = mapper; + } + + public async Task> Handle(GetAccessTokenList request, CancellationToken cancellationToken) + { + var accessTokens = + await _accessTokenService.GetListAsync(request.OrganizationId, request.Filter); + + var creatorIds = accessTokens.Items.Select(x => x.CreatorId); + var creators = await _userService.GetListAsync(creatorIds); + + var accessTokenVms = _mapper.Map>(accessTokens); + foreach (var accessToken in accessTokens.Items) + { + var accessTokenVm = accessTokenVms.Items.First(x => x.Id == accessToken.Id); + accessTokenVm.Creator = _mapper.Map(creators.FirstOrDefault(x => x.Id == accessToken.CreatorId)); + accessTokenVm.Token = accessTokenVm.Token[..15] + "**************"; + } + + return accessTokenVms; + } +} \ No newline at end of file diff --git a/modules/back-end/src/Application/AccessTokens/IsAccessTokenNameUsed.cs b/modules/back-end/src/Application/AccessTokens/IsAccessTokenNameUsed.cs new file mode 100644 index 000000000..b4d72fc58 --- /dev/null +++ b/modules/back-end/src/Application/AccessTokens/IsAccessTokenNameUsed.cs @@ -0,0 +1,28 @@ +namespace Application.AccessTokens; + +public class IsAccessTokenNameUsed : IRequest +{ + public Guid OrganizationId { get; set; } + + public string Name { get; set; } +} + +public class IsAccessTokenNameUsedHandler : IRequestHandler +{ + private readonly IAccessTokenService _service; + + public IsAccessTokenNameUsedHandler(IAccessTokenService service) + { + _service = service; + } + + public async Task Handle(IsAccessTokenNameUsed request, CancellationToken cancellationToken) + { + var isNameUsed = await _service.AnyAsync(x => + x.OrganizationId == request.OrganizationId && + string.Equals(x.Name, request.Name, StringComparison.OrdinalIgnoreCase) + ); + + return isNameUsed; + } +} \ No newline at end of file diff --git a/modules/back-end/src/Application/AccessTokens/MapperProfile.cs b/modules/back-end/src/Application/AccessTokens/MapperProfile.cs new file mode 100644 index 000000000..db401ba40 --- /dev/null +++ b/modules/back-end/src/Application/AccessTokens/MapperProfile.cs @@ -0,0 +1,13 @@ +using Application.Bases.Models; +using Domain.AccessTokens; + +namespace Application.AccessTokens; + +public class MapperProfile : Profile +{ + public MapperProfile() + { + CreateMap(); + CreateMap, PagedResult>(); + } +} \ No newline at end of file diff --git a/modules/back-end/src/Application/AccessTokens/ToggleAccessTokenStatus.cs b/modules/back-end/src/Application/AccessTokens/ToggleAccessTokenStatus.cs new file mode 100644 index 000000000..e860e2902 --- /dev/null +++ b/modules/back-end/src/Application/AccessTokens/ToggleAccessTokenStatus.cs @@ -0,0 +1,26 @@ +namespace Application.AccessTokens; + +public class ToggleAccessTokenStatus : IRequest +{ + public Guid Id { get; set; } +} + +public class ToggleAccessTokenStatusHandler : IRequestHandler +{ + private readonly IAccessTokenService _service; + + public ToggleAccessTokenStatusHandler(IAccessTokenService service) + { + _service = service; + } + + public async Task Handle(ToggleAccessTokenStatus request, CancellationToken cancellationToken) + { + var accessToken = await _service.GetAsync(request.Id); + accessToken.ToggleStatus(); + + await _service.UpdateAsync(accessToken); + + return true; + } +} \ No newline at end of file diff --git a/modules/back-end/src/Application/AccessTokens/UpdateAccessToken.cs b/modules/back-end/src/Application/AccessTokens/UpdateAccessToken.cs new file mode 100644 index 000000000..47ed1c46a --- /dev/null +++ b/modules/back-end/src/Application/AccessTokens/UpdateAccessToken.cs @@ -0,0 +1,39 @@ +using Application.Bases; + +namespace Application.AccessTokens; + +public class UpdateAccessToken : IRequest +{ + public Guid Id { get; set; } + + public string Name { get; set; } +} + +public class UpdateAccessTokenValidator : AbstractValidator +{ + public UpdateAccessTokenValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithErrorCode(ErrorCodes.NameIsRequired); + } +} + +public class UpdateAccessTokenHandler : IRequestHandler +{ + private readonly IAccessTokenService _service; + + public UpdateAccessTokenHandler(IAccessTokenService service) + { + _service = service; + } + + public async Task Handle(UpdateAccessToken request, CancellationToken cancellationToken) + { + var accessToken = await _service.GetAsync(request.Id); + accessToken.UpdateName(request.Name); + + await _service.UpdateAsync(accessToken); + + return true; + } +} \ No newline at end of file diff --git a/modules/back-end/src/Application/Bases/ErrorCodes.cs b/modules/back-end/src/Application/Bases/ErrorCodes.cs index 73ea11479..9e1387365 100644 --- a/modules/back-end/src/Application/Bases/ErrorCodes.cs +++ b/modules/back-end/src/Application/Bases/ErrorCodes.cs @@ -5,10 +5,12 @@ public static class ErrorCodes // general public const string InternalServerError = nameof(InternalServerError); public const string Unauthorized = nameof(Unauthorized); + public const string Forbidden = nameof(Forbidden); // application public const string ResourceNotFound = nameof(ResourceNotFound); public const string InvalidJson = nameof(InvalidJson); + public const string EntityExistsAlready = nameof(EntityExistsAlready); // identity error codes public const string MethodIsRequired = nameof(MethodIsRequired); @@ -65,4 +67,8 @@ public static class ErrorCodes public const string EventTypeIsRequired = nameof(EventTypeIsRequired); public const string EventNameIsRequired = nameof(EventNameIsRequired); public const string MetricIsBeingUsedByExperiment = nameof(MetricIsBeingUsedByExperiment); + + // access tokens + public const string InvalidAccessTokenType = nameof(InvalidAccessTokenType); + public const string ServiceAccessTokenMustDefinePolicies = nameof(ServiceAccessTokenMustDefinePolicies); } \ No newline at end of file diff --git a/modules/back-end/src/Application/Groups/IsGroupNameUsed.cs b/modules/back-end/src/Application/Groups/IsGroupNameUsed.cs index 5a123d0fe..a1b187f28 100644 --- a/modules/back-end/src/Application/Groups/IsGroupNameUsed.cs +++ b/modules/back-end/src/Application/Groups/IsGroupNameUsed.cs @@ -19,7 +19,7 @@ public IsGroupNameUsedHandler(IGroupService service) public async Task Handle(IsGroupNameUsed request, CancellationToken cancellationToken) { var isNameUsed = - await _service.AnyAsync(x => x.OrganizationId == request.OrganizationId && x.Name == request.Name); + await _service.AnyAsync(x => x.OrganizationId == request.OrganizationId && string.Equals(x.Name, request.Name, StringComparison.OrdinalIgnoreCase)); return isNameUsed; } diff --git a/modules/back-end/src/Application/Policies/IsPolicyNameUsed.cs b/modules/back-end/src/Application/Policies/IsPolicyNameUsed.cs index 9d28da1f0..1ca097a9f 100644 --- a/modules/back-end/src/Application/Policies/IsPolicyNameUsed.cs +++ b/modules/back-end/src/Application/Policies/IsPolicyNameUsed.cs @@ -19,7 +19,7 @@ public IsPolicyNameUsedHandler(IPolicyService service) public async Task Handle(IsPolicyNameUsed request, CancellationToken cancellationToken) { var isNameUsed = - await _service.AnyAsync(x => x.OrganizationId == request.OrganizationId && x.Name == request.Name); + await _service.AnyAsync(x => x.OrganizationId == request.OrganizationId && string.Equals(x.Name, request.Name, StringComparison.OrdinalIgnoreCase)); return isNameUsed; } diff --git a/modules/back-end/src/Application/Resources/ResourceVm.cs b/modules/back-end/src/Application/Resources/ResourceVm.cs index 918a5a63f..10b0ccb56 100644 --- a/modules/back-end/src/Application/Resources/ResourceVm.cs +++ b/modules/back-end/src/Application/Resources/ResourceVm.cs @@ -7,4 +7,6 @@ public class ResourceVm public string Name { get; set; } public string Rn { get; set; } + + public string Type { get; set; } } \ No newline at end of file diff --git a/modules/back-end/src/Application/Segments/IsSegmentNameUsed.cs b/modules/back-end/src/Application/Segments/IsSegmentNameUsed.cs index 50109f8d7..cb3e819f5 100644 --- a/modules/back-end/src/Application/Segments/IsSegmentNameUsed.cs +++ b/modules/back-end/src/Application/Segments/IsSegmentNameUsed.cs @@ -18,6 +18,6 @@ public IsSegmentNameUsedHandler(ISegmentService service) public async Task Handle(IsSegmentNameUsed request, CancellationToken cancellationToken) { - return await _service.AnyAsync(x => !x.IsArchived && x.EnvId == request.EnvId && x.Name == request.Name); + return await _service.AnyAsync(x => !x.IsArchived && x.EnvId == request.EnvId && string.Equals(x.Name, request.Name, StringComparison.OrdinalIgnoreCase)); } } \ No newline at end of file diff --git a/modules/back-end/src/Application/Services/IAccessTokenService.cs b/modules/back-end/src/Application/Services/IAccessTokenService.cs new file mode 100644 index 000000000..d2557372d --- /dev/null +++ b/modules/back-end/src/Application/Services/IAccessTokenService.cs @@ -0,0 +1,12 @@ +using Application.AccessTokens; +using Application.Bases.Models; +using Domain.AccessTokens; + +namespace Application.Services; + +public interface IAccessTokenService : IService +{ + Task> GetListAsync(Guid organizationId, AccessTokenFilter filter); + + Task DeleteAsync(Guid id); +} \ No newline at end of file diff --git a/modules/back-end/src/Application/Users/MapperProfile.cs b/modules/back-end/src/Application/Users/MapperProfile.cs new file mode 100644 index 000000000..25cab8b93 --- /dev/null +++ b/modules/back-end/src/Application/Users/MapperProfile.cs @@ -0,0 +1,11 @@ +using Domain.Users; + +namespace Application.Users; + +public class MapperProfile : AutoMapper.Profile +{ + public MapperProfile() + { + CreateMap(); + } +} \ No newline at end of file diff --git a/modules/back-end/src/Application/Users/UserVm.cs b/modules/back-end/src/Application/Users/UserVm.cs new file mode 100644 index 000000000..fa57e8a68 --- /dev/null +++ b/modules/back-end/src/Application/Users/UserVm.cs @@ -0,0 +1,10 @@ +namespace Application.Users; + +public class UserVm +{ + public string Id { get; set; } + + public string Name { get; set; } + + public string Email { get; set; } +} \ No newline at end of file diff --git a/modules/back-end/src/Domain/AccessTokens/AccessToken.cs b/modules/back-end/src/Domain/AccessTokens/AccessToken.cs new file mode 100644 index 000000000..d0d69a425 --- /dev/null +++ b/modules/back-end/src/Domain/AccessTokens/AccessToken.cs @@ -0,0 +1,57 @@ +using Domain.Policies; + +namespace Domain.AccessTokens; + +public class AccessToken : AuditedEntity +{ + public Guid OrganizationId { get; set; } + + public string Name { get; set; } + + public string Type { get; set; } + + public string Status { get; set; } + + public string Token { get; set; } + + public Guid CreatorId { get; set; } + + public IEnumerable Permissions { get; set; } + + public DateTime? LastUsedAt { get; set; } + + public AccessToken( + Guid organizationId, + Guid creatorId, + string name, + string type, + IEnumerable permissions) + { + OrganizationId = organizationId; + CreatorId = creatorId; + Name = name; + + Status = AccessTokenStatus.Active; + Type = type; + Permissions = permissions; + + Token = $"api-{TokenHelper.New(Guid.NewGuid())}"; + } + + public void RefreshLastUsedAt() + { + LastUsedAt = DateTime.UtcNow; + } + + public void ToggleStatus() + { + Status = Status == AccessTokenStatus.Active ? AccessTokenStatus.Inactive : AccessTokenStatus.Active; + UpdatedAt = DateTime.UtcNow; + } + + public void UpdateName(string name) + { + Name = name; + UpdatedAt = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/modules/back-end/src/Domain/AccessTokens/AccessTokenStatus.cs b/modules/back-end/src/Domain/AccessTokens/AccessTokenStatus.cs new file mode 100644 index 000000000..cefd171c0 --- /dev/null +++ b/modules/back-end/src/Domain/AccessTokens/AccessTokenStatus.cs @@ -0,0 +1,15 @@ +namespace Domain.AccessTokens; + +public class AccessTokenStatus +{ + public const string Active = "Active"; + + public const string Inactive = "Inactive"; + + public static readonly string[] All = { Active, Inactive }; + + public static bool IsDefined(string type) + { + return All.Contains(type); + } +} \ No newline at end of file diff --git a/modules/back-end/src/Domain/AccessTokens/AccessTokenTypes.cs b/modules/back-end/src/Domain/AccessTokens/AccessTokenTypes.cs new file mode 100644 index 000000000..1f4f1adaf --- /dev/null +++ b/modules/back-end/src/Domain/AccessTokens/AccessTokenTypes.cs @@ -0,0 +1,15 @@ +namespace Domain.AccessTokens; + +public class AccessTokenTypes +{ + public const string Personal = "Personal"; + + public const string Service = "Service"; + + public static readonly string[] All = { Personal, Service }; + + public static bool IsDefined(string type) + { + return All.Contains(type); + } +} \ No newline at end of file diff --git a/modules/back-end/src/Domain/Resources/Resource.cs b/modules/back-end/src/Domain/Resources/Resource.cs index 5609ea06d..8cb1f455a 100644 --- a/modules/back-end/src/Domain/Resources/Resource.cs +++ b/modules/back-end/src/Domain/Resources/Resource.cs @@ -7,4 +7,54 @@ public class Resource public string Name { get; set; } public string Rn { get; set; } + + public string Type { get; set; } + + public static readonly Resource All = new() + { + Id = new Guid("2bdcb290-2e1b-40d7-bdd1-697fb2193292"), + Name = "All", + Rn = "*", + Type = ResourceType.All + }; + + public static readonly Resource AllAccount = new() + { + Id = new Guid("e394832e-bd98-43de-b174-e0c98e03d19d"), + Name = "Account", + Rn = "account/*", + Type = ResourceType.Account + }; + + public static readonly Resource AllIam = new() + { + Id = new Guid("d8791bd2-ca85-4629-a439-1dce20764211"), + Name = "IAM", + Rn = "iam/*", + Type = ResourceType.IAM + }; + + public static readonly Resource AllAccessToken = new() + { + Id = new Guid("150083da-e20f-4670-948c-b842cf8a91a4"), + Name = "Access token", + Rn = "access-token/*", + Type = ResourceType.AccessToken + }; + + public static readonly Resource AllProject = new() + { + Id = new Guid("e77679a2-e79b-43e5-aa9f-fd6c980239be"), + Name = "project", + Rn = "project/*", + Type = ResourceType.Project + }; + + public static readonly Resource AllProjectEnv = new() + { + Id = new Guid("c62ed37a-74a9-4987-8ef4-b5a16127f307"), + Name = "env", + Rn = "project/*:env/*", + Type = ResourceType.Env + }; } \ No newline at end of file diff --git a/modules/back-end/src/Domain/Resources/ResourceType.cs b/modules/back-end/src/Domain/Resources/ResourceType.cs index e1bdf237c..51af785e7 100644 --- a/modules/back-end/src/Domain/Resources/ResourceType.cs +++ b/modules/back-end/src/Domain/Resources/ResourceType.cs @@ -4,7 +4,11 @@ public class ResourceType { public const string All = "*"; - public const string General = "general"; + public const string Account = "account"; + + public const string IAM = "iam"; + + public const string AccessToken = "access-token"; public const string Project = "project"; diff --git a/modules/back-end/src/Domain/Triggers/Trigger.cs b/modules/back-end/src/Domain/Triggers/Trigger.cs index 9e65ceee3..b1c05dc29 100644 --- a/modules/back-end/src/Domain/Triggers/Trigger.cs +++ b/modules/back-end/src/Domain/Triggers/Trigger.cs @@ -1,4 +1,3 @@ -using System.Text; using Domain.FeatureFlags; namespace Domain.Triggers; @@ -28,7 +27,7 @@ public Trigger(Guid id, Guid targetId, string type, string action, string descri TargetId = targetId; Type = type; Action = action; - Token = NewToken(); + Token = TokenHelper.New(Id); Description = description ?? string.Empty; IsEnabled = true; @@ -38,27 +37,11 @@ public Trigger(Guid id, Guid targetId, string type, string action, string descri public void ResetToken() { - Token = NewToken(); + Token = TokenHelper.New(Id); UpdatedAt = DateTime.UtcNow; } - private string NewToken() - { - // timestamp in millis, length is 13 - var reversedTimestamp = - new string(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString().Reverse().ToArray()); - - // header length is (13 + 2) * 4/3 = 20, we trim '=' character, so got 18 chars - var header = Convert.ToBase64String(Encoding.UTF8.GetBytes(reversedTimestamp)).TrimEnd('='); - - // 22 chars - var guid = GuidHelper.Encode(Id); - - // 18 + 22 = 40 chars - return $"{header}{guid}"; - } - public static bool TryParseToken(string token, out Guid id) { id = Guid.Empty; diff --git a/modules/back-end/src/Domain/Utils/TokenHelper.cs b/modules/back-end/src/Domain/Utils/TokenHelper.cs new file mode 100644 index 000000000..17725331c --- /dev/null +++ b/modules/back-end/src/Domain/Utils/TokenHelper.cs @@ -0,0 +1,22 @@ +using System.Text; + +namespace Domain.Utils; + +public static class TokenHelper +{ + public static string New(Guid id) + { + // timestamp in millis, length is 13 + var reversedTimestamp = + new string(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString().Reverse().ToArray()); + + // header length is (13 + 2) * 4/3 = 20, we trim '=' character, so got 18 chars + var header = Convert.ToBase64String(Encoding.UTF8.GetBytes(reversedTimestamp)).TrimEnd('='); + + // 22 chars + var guid = GuidHelper.Encode(id); + + // 18 + 22 = 40 chars + return $"{header}{guid}"; + } +} \ No newline at end of file diff --git a/modules/back-end/src/Infrastructure/AccessTokens/AccessTokenService.cs b/modules/back-end/src/Infrastructure/AccessTokens/AccessTokenService.cs new file mode 100644 index 000000000..543719da3 --- /dev/null +++ b/modules/back-end/src/Infrastructure/AccessTokens/AccessTokenService.cs @@ -0,0 +1,51 @@ +using Application.AccessTokens; +using Application.Bases.Models; +using Domain.AccessTokens; +using MongoDB.Driver; +using MongoDB.Driver.Linq; + +namespace Infrastructure.AccessTokens; + +public class AccessTokenService : MongoDbService, IAccessTokenService +{ + public AccessTokenService(MongoDbClient mongoDb) : base(mongoDb) + { + } + + public async Task> GetListAsync(Guid organizationId, AccessTokenFilter filter) + { + var query = Queryable.Where(x => x.OrganizationId == organizationId); + + var name = filter.Name; + if (!string.IsNullOrWhiteSpace(name)) + { + query = query.Where(x => x.Name.Contains(name, StringComparison.CurrentCultureIgnoreCase)); + } + + var type = filter.Type; + if (!string.IsNullOrWhiteSpace(type)) + { + query = query.Where(x => x.Type == type); + } + + var creatorId = filter.CreatorId; + if (creatorId.HasValue) + { + query = query.Where(x => x.CreatorId == creatorId.Value); + } + + var totalCount = await query.CountAsync(); + var items = await query + .Skip(filter.PageIndex * filter.PageSize) + .OrderByDescending(x => x.CreatedAt) + .Take(filter.PageSize) + .ToListAsync(); + + return new PagedResult(totalCount, items); + } + + public async Task DeleteAsync(Guid id) + { + await Collection.DeleteOneAsync(x => x.Id == id); + } +} \ No newline at end of file diff --git a/modules/back-end/src/Infrastructure/ConfigureServices.cs b/modules/back-end/src/Infrastructure/ConfigureServices.cs index c764cf6bd..7cc4f1552 100644 --- a/modules/back-end/src/Infrastructure/ConfigureServices.cs +++ b/modules/back-end/src/Infrastructure/ConfigureServices.cs @@ -2,6 +2,7 @@ using Domain.Identity; using Domain.Messages; using Domain.Users; +using Infrastructure.AccessTokens; using Infrastructure.AuditLogs; using Infrastructure.DataSync; using Infrastructure.EndUsers; @@ -94,6 +95,7 @@ public static class ConfigureServices services.AddTransient(); services.AddTransient(); services.AddSingleton(); + services.AddTransient(); return services; } diff --git a/modules/back-end/src/Infrastructure/MongoDb/MongoDbClient.cs b/modules/back-end/src/Infrastructure/MongoDb/MongoDbClient.cs index ae3334333..bd6cc7634 100644 --- a/modules/back-end/src/Infrastructure/MongoDb/MongoDbClient.cs +++ b/modules/back-end/src/Infrastructure/MongoDb/MongoDbClient.cs @@ -1,3 +1,4 @@ +using Domain.AccessTokens; using Domain.AuditLogs; using Domain.EndUsers; using Domain.ExperimentMetrics; @@ -59,6 +60,8 @@ public MongoDbClient(IOptions options) { typeof(Experiment), "Experiments" }, { typeof(ExperimentMetric), "ExperimentMetrics" }, + + { typeof(AccessToken), "AccessTokens" }, }; public IMongoCollection CollectionOf() diff --git a/modules/back-end/src/Infrastructure/Resources/ResourceService.cs b/modules/back-end/src/Infrastructure/Resources/ResourceService.cs index 6934caa33..7d54fc633 100644 --- a/modules/back-end/src/Infrastructure/Resources/ResourceService.cs +++ b/modules/back-end/src/Infrastructure/Resources/ResourceService.cs @@ -1,5 +1,4 @@ using Application.Resources; -using Application.Services; using Domain.Organizations; using Domain.Projects; using Domain.Resources; @@ -24,66 +23,16 @@ public async Task> GetResourcesAsync(Guid organizationId, return filter.Type switch { - ResourceType.All => GetAll(name), - ResourceType.General => GetGeneral(name), + ResourceType.All => new[] { Resource.All }, + ResourceType.Account => new[] { Resource.AllAccount }, + ResourceType.IAM => new[] { Resource.AllIam }, + ResourceType.AccessToken => new[] { Resource.AllAccessToken }, ResourceType.Env => await GetEnvsAsync(organizationId, name), ResourceType.Project => await GetProjectsAsync(organizationId, name), _ => Array.Empty() }; } - private IEnumerable GetAll(string name) - { - var resources = new List - { - new() - { - Id = new Guid("2bdcb290-2e1b-40d7-bdd1-697fb2193292"), - Name = "All", - Rn = "*" - } - }; - - if (!string.IsNullOrWhiteSpace(name)) - { - resources = resources.Where(x => x.Name.Contains(name, StringComparison.OrdinalIgnoreCase)).ToList(); - } - - return resources; - } - - private IEnumerable GetGeneral(string name) - { - var resources = new List - { - new() - { - Id = new Guid("e394832e-bd98-43de-b174-e0c98e03d19d"), - Name = "Account", - Rn = "account" - }, - new() - { - Id = new Guid("d8791bd2-ca85-4629-a439-1dce20764211"), - Name = "IAM", - Rn = "iam" - }, - new() - { - Id = new Guid("150083da-e20f-4670-948c-b842cf8a91a4"), - Name = "Project", - Rn = "project" - } - }; - - if (!string.IsNullOrWhiteSpace(name)) - { - resources = resources.Where(x => x.Name.Contains(name, StringComparison.OrdinalIgnoreCase)).ToList(); - } - - return resources; - } - public async Task> GetProjectsAsync(Guid organizationId, string name) { var query = MongoDb.QueryableOf() @@ -105,8 +54,11 @@ public async Task> GetProjectsAsync(Guid organizationId, s { Id = x.Id, Name = x.Name, - Rn = x.Rn - }); + Rn = x.Rn, + Type = ResourceType.Project + }).ToList(); + + resources.Insert(0, Resource.AllProject); return resources; } @@ -139,8 +91,11 @@ select new { Id = x.Id, Name = x.Name, - Rn = x.Rn - }); + Rn = x.Rn, + Type = ResourceType.Env + }).ToList(); + + resources.Insert(0, Resource.AllProjectEnv); return resources; } } \ No newline at end of file diff --git a/modules/front-end/src/app/core/components/access-token-drawer/access-token-drawer.component.html b/modules/front-end/src/app/core/components/access-token-drawer/access-token-drawer.component.html new file mode 100644 index 000000000..edf5beb13 --- /dev/null +++ b/modules/front-end/src/app/core/components/access-token-drawer/access-token-drawer.component.html @@ -0,0 +1,119 @@ + + +
+ + + Name + + + + + Access token name cannot be empty + This access token name is not available + + + + + + Type + + + + + + + + + +
+ +
+ Permissions +
+ + +
+ +
+
+ + + +
+ +
+
+ {{resource}} +
+
+
+
+
+
+
+
+
+ + + + + + +
+ + + +
+ + +
+
{{tokenName}}:
+
+ {{tokenValue}} + +
+
+
+
+ + + +
+ diff --git a/modules/front-end/src/app/core/components/access-token-drawer/access-token-drawer.component.less b/modules/front-end/src/app/core/components/access-token-drawer/access-token-drawer.component.less new file mode 100644 index 000000000..396dbb593 --- /dev/null +++ b/modules/front-end/src/app/core/components/access-token-drawer/access-token-drawer.component.less @@ -0,0 +1,58 @@ +@import "variables"; + +.error-message { + color: @red60-color; +} + +i { + cursor: pointer; +} + +#confirm-modal-ok { + color: @white; + + &:hover, &:focus { + color: @white; + border-color: #61d4aa; + background: #61d4aa; + } +} + +.token-wrapper { + margin: 32px 0; + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + font-size: 15px; +} + +.resource-type { + .permissions { + display: flex; + flex-direction: column; + gap: 8px; + + .permission { + display: flex; + flex-direction: column; + + .permission-header { + display: flex; + justify-content: flex-start; + gap: 8px; + align-items: center; + } + + .permission-resources { + border-top: 1px solid @grey40-color; + display: flex; + flex-direction: column; + + .permission-resource { + padding: 0 8px; + } + } + } + } +} diff --git a/modules/front-end/src/app/core/components/access-token-drawer/access-token-drawer.component.ts b/modules/front-end/src/app/core/components/access-token-drawer/access-token-drawer.component.ts new file mode 100644 index 000000000..c24e30c82 --- /dev/null +++ b/modules/front-end/src/app/core/components/access-token-drawer/access-token-drawer.component.ts @@ -0,0 +1,257 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { NzMessageService } from 'ng-zorro-antd/message'; +import { debounceTime, first, map, switchMap } from "rxjs/operators"; +import { PolicyService } from "@services/policy.service"; +import { + AccessTokenTypeEnum, + IAccessToken, +} from "@features/safe/integrations/access-tokens/types/access-token"; +import { AccessTokenService } from "@services/access-token.service"; +import { PermissionsService } from "@services/permissions.service"; +import { + EffectEnum, + generalResourceRNPattern, + permissionActions, + ResourceTypeAccount, + ResourceTypeIAM +} from "@shared/policy"; +import { NzModalService } from "ng-zorro-antd/modal"; +import { copyToClipboard, uuidv4 } from "@utils/index"; +import { + preProcessPermissions, + IPermissionStatementGroup, postProcessPermissions +} from "@features/safe/integrations/access-tokens/types/permission-helper"; +import { + ResourceType, ResourceTypeAccessToken, + ResourceTypeEnv, + ResourceTypeProject +} from "@shared/policy"; + +@Component({ + selector: 'access-token-drawer', + templateUrl: './access-token-drawer.component.html', + styleUrls: ['./access-token-drawer.component.less'] +}) +export class AccessTokenDrawerComponent { + private _accessToken: IAccessToken; + isEditing: boolean = false; + + // This property is used to define the order of displaying the resource types, it also defines the resource types applicable to OPEN API + // TODO replace with real open API resource types + resourceTypes: ResourceType[] = [ + ResourceTypeAccount, + ResourceTypeIAM, + ResourceTypeAccessToken, + ResourceTypeProject, + ResourceTypeEnv + ]; + + authorizedResourceTypes: ResourceType[] = []; + permissions: { [key: string]: IPermissionStatementGroup }; + + @Input() + set accessToken(accessToken: IAccessToken) { + this.isEditing = accessToken && !!accessToken.id; + if (this.isEditing) { + this.permissions = preProcessPermissions(accessToken.permissions); + this.title = $localize`:@@integrations.access-token.access-token-drawer.edit-title:Edit Access Token`; + } else { + accessToken = {name: null, type: AccessTokenTypeEnum.Personal}; + this.setAuthorizedPermissions(); + this.title = $localize`:@@integrations.access-token.access-token-drawer.add-title:Add Access Token`; + } + + this.isServiceAccessToken = accessToken.type === AccessTokenTypeEnum.Service; + this.patchForm(accessToken); + this._accessToken = accessToken; + this.authorizedResourceTypes = this.resourceTypes.filter((rt) => this.permissions[rt.type]?.statements?.length > 0); + } + + @Input() visible: boolean = false; + @Output() close: EventEmitter = new EventEmitter(); + title: string = ''; + + canTakeActionOnPersonalAccessToken = false; + canTakeActionOnServiceAccessToken = false; + + constructor( + private fb: FormBuilder, + private policyService: PolicyService, + private permissionsService: PermissionsService, + private accessTokenService: AccessTokenService, + private modal: NzModalService, + private message: NzMessageService + ) { + this.form = this.fb.group({ + name: ['', [Validators.required], [this.nameAsyncValidator], 'change'], + type: [AccessTokenTypeEnum.Personal, [Validators.required]] + }); + + this.canTakeActionOnPersonalAccessToken = this.permissionsService.canTakeAction(generalResourceRNPattern.accessToken, permissionActions.ManagePersonalAccessTokens); + this.canTakeActionOnServiceAccessToken = this.permissionsService.canTakeAction(generalResourceRNPattern.accessToken, permissionActions.ManageServiceAccessTokens); + } + + isServiceAccessToken: boolean = false + + form: FormGroup; + + get accessToken() { + return this._accessToken; + } + + patchForm(accessToken: Partial) { + this.form.patchValue({ + name: accessToken.name, + type: accessToken.type, + }); + } + + onClose() { + this.close.emit(); + } + + onTypeChange() { + const {type} = this.form.value; + this.isServiceAccessToken = type === AccessTokenTypeEnum.Service; + } + + setAuthorizedPermissions() { + const hasOwnerPolicy = this.permissionsService.policies.some((policy) => policy.name === 'Owner' && policy.type === 'SysManaged'); + + let permissions = []; + if (hasOwnerPolicy) { + permissions = Object.keys(permissionActions) + .map((property) => { + const {resourceType, name} = permissionActions[property]; + return { + id: uuidv4(), + resourceType, + effect: EffectEnum.Allow, + actions: [name], + resources: [generalResourceRNPattern[resourceType]] + } + }) + } else { + permissions = this.permissionsService.permissions; + } + + permissions = permissions.filter((permission) => this.resourceTypes.some((rt) => rt.type === permission.resourceType)); + this.permissions = preProcessPermissions(permissions); + } + + nameAsyncValidator = (control: FormControl) => control.valueChanges.pipe( + debounceTime(300), + switchMap(value => this.accessTokenService.isNameUsed(value as string)), + map(isNameUsed => { + switch (isNameUsed) { + case true: + return {error: true, duplicated: true}; + case undefined: + return {error: true, unknown: true}; + default: + return null; + } + }), + first() + ); + + updatePermissionsAllChecked(statementGroup: IPermissionStatementGroup) { + statementGroup.indeterminate = false; + if (statementGroup.allChecked) { + statementGroup.statements = statementGroup.statements.map(item => ({ + ...item, + checked: true + })); + } else { + statementGroup.statements = statementGroup.statements.map(item => ({ + ...item, + checked: false + })); + } + } + + updatePermissionSingleChecked(statementGroup: IPermissionStatementGroup) { + if (statementGroup.statements.every(item => !item.checked)) { + statementGroup.allChecked = false; + statementGroup.indeterminate = false; + } else if (statementGroup.statements.every(item => item.checked)) { + statementGroup.allChecked = true; + statementGroup.indeterminate = false; + } else { + statementGroup.indeterminate = true; + } + } + + isLoading: boolean = false; + + tokenName = ''; + tokenValue = ''; + isCreationConfirmModalVisible = false; + + doSubmit() { + if (this.form.invalid) { + for (const i in this.form.controls) { + this.form.controls[i].markAsDirty(); + this.form.controls[i].updateValueAndValidity(); + } + + return; + } + + const {name, type} = this.form.value; + + if ((type === AccessTokenTypeEnum.Personal && !this.canTakeActionOnPersonalAccessToken) || (type === AccessTokenTypeEnum.Service && !this.canTakeActionOnServiceAccessToken)) { + this.message.warning($localize`:@@permissions.need-permissions-to-operate:You don't have permissions to take this action, please contact the admin to grant you the necessary permissions`); + return; + } + + this.isLoading = true; + if (this.isEditing) { + this.accessTokenService.update(this.accessToken.id, name).subscribe({ + next: _ => { + this.isLoading = false; + this.close.emit({isEditing: true, id: this.accessToken.id, name: name}); + this.message.success($localize`:@@common.operation-success:Operation succeeded`); + }, + error: _ => { + this.message.error($localize`:@@common.operation-failed-try-again:Operation failed, please try again`); + this.isLoading = false; + } + } + ); + } else { + const policies = this.isServiceAccessToken ? postProcessPermissions(this.permissions) : []; + + this.accessTokenService.create(name, type, policies).subscribe({ + next: ({name, token}) => { + this.isLoading = false; + this.close.emit({isEditing: false}); + this.message.success($localize`:@@common.operation-success:Operation succeeded`); + this.form.reset(); + this.tokenName = name; + this.tokenValue = token; + this.isCreationConfirmModalVisible = true; + }, + error: (e) => { + this.isLoading = false; + if (e.errors[0] === 'ServiceAccessTokenMustDefinePolicies') { + this.message.error($localize`:@@integrations.access-token.service-access-token-must-define-policies:Policies are mandatory for service type access tokens`); + } + } + } + ) + } + } + + copyText(event, text: string) { + copyToClipboard(text).then( + () => this.message.success($localize`:@@common.copy-success:Copied`) + ); + } + + actionTokenTypes = [ + AccessTokenTypeEnum.Personal, + AccessTokenTypeEnum.Service + ] +} diff --git a/modules/front-end/src/app/core/components/change-review/change-review.component.less b/modules/front-end/src/app/core/components/change-review/change-review.component.less index 96efddd29..1e41b1728 100644 --- a/modules/front-end/src/app/core/components/change-review/change-review.component.less +++ b/modules/front-end/src/app/core/components/change-review/change-review.component.less @@ -28,14 +28,14 @@ padding: 10px; border: 1px solid #d2d2d2; margin: 12px 0; - border-left: 3px solid #ff912b; + border-left: 3px solid @yellow5-color; border-radius: 10px; .warning { display: flex; .warning-icon { - color: #ff912b; + color: @yellow5-color; } .warning-content { diff --git a/modules/front-end/src/app/core/components/env-drawer/env-drawer.component.ts b/modules/front-end/src/app/core/components/env-drawer/env-drawer.component.ts index 05a332e47..c3afb8fad 100644 --- a/modules/front-end/src/app/core/components/env-drawer/env-drawer.component.ts +++ b/modules/front-end/src/app/core/components/env-drawer/env-drawer.component.ts @@ -4,7 +4,7 @@ import { NzMessageService } from 'ng-zorro-antd/message'; import { IEnvironment } from '@shared/types'; import { EnvService } from '@services/env.service'; import { ProjectService } from "@services/project.service"; -import { generalResourceRNPattern, permissionActions } from "@shared/permissions"; +import { generalResourceRNPattern, permissionActions } from "@shared/policy"; import { PermissionsService } from "@services/permissions.service"; import { debounceTime, first, map, switchMap } from "rxjs/operators"; import { slugify } from "@utils/index"; diff --git a/modules/front-end/src/app/core/components/expt-rules-drawer/expt-rules-drawer.component.less b/modules/front-end/src/app/core/components/expt-rules-drawer/expt-rules-drawer.component.less index 9962bfc4e..a895ccfa8 100644 --- a/modules/front-end/src/app/core/components/expt-rules-drawer/expt-rules-drawer.component.less +++ b/modules/front-end/src/app/core/components/expt-rules-drawer/expt-rules-drawer.component.less @@ -9,14 +9,14 @@ padding: 10px; border: 1px solid #d2d2d2; margin-bottom: 15px; - border-left: 3px solid #ff912b; + border-left: 3px solid @yellow5-color; border-radius: 10px; .warning { display: flex; .warning-icon { - color: #ff912b; + color: @yellow5-color; } .warning-content { padding-left: 5px; diff --git a/modules/front-end/src/app/core/components/guide/guide.component.html b/modules/front-end/src/app/core/components/guide/guide.component.html index 43303a2d1..08ca71c46 100644 --- a/modules/front-end/src/app/core/components/guide/guide.component.html +++ b/modules/front-end/src/app/core/components/guide/guide.component.html @@ -59,7 +59,7 @@

How-to guides with demo & SDK

-
{{trigger.description}}
- {{getTriggerUrl(trigger.token)}} - {{getTriggerUrl(trigger.token)}} + {{getTriggerUrl(trigger.token)}} +
diff --git a/modules/front-end/src/app/features/safe/feature-flags/components/flag-triggers/flag-triggers.component.less b/modules/front-end/src/app/features/safe/feature-flags/components/flag-triggers/flag-triggers.component.less index 63b73cdad..c752afe2f 100644 --- a/modules/front-end/src/app/features/safe/feature-flags/components/flag-triggers/flag-triggers.component.less +++ b/modules/front-end/src/app/features/safe/feature-flags/components/flag-triggers/flag-triggers.component.less @@ -178,6 +178,15 @@ .trigger-url{ color: rgba(0, 0, 0, 0.85); + + + .copy-icon { + font-size: 13px; + + &:hover { + color: @green60-color; + } + } display: flex; align-items: center; @@ -195,7 +204,7 @@ margin-top: 5px; .warning-icon { - color: #ff912b; + color: @yellow5-color; } } } diff --git a/modules/front-end/src/app/features/safe/feature-flags/components/flag-triggers/flag-triggers.component.ts b/modules/front-end/src/app/features/safe/feature-flags/components/flag-triggers/flag-triggers.component.ts index 2a317179a..7d883c90e 100644 --- a/modules/front-end/src/app/features/safe/feature-flags/components/flag-triggers/flag-triggers.component.ts +++ b/modules/front-end/src/app/features/safe/feature-flags/components/flag-triggers/flag-triggers.component.ts @@ -4,6 +4,7 @@ import { FlagTriggerService } from '@services/flag-trigger.service'; import { FlagTriggerActionEnum, FlagTriggerTypeEnum, IFlagTrigger } from '../../types/flag-triggers'; import {IFeatureFlag} from "@features/safe/feature-flags/types/details"; import {FeatureFlagService} from "@services/feature-flag.service"; +import { copyToClipboard } from "@utils/index"; @Component({ selector: 'flag-triggers', @@ -104,6 +105,12 @@ export class FlagTriggersComponent implements OnInit { return this.flagTriggerService.getTriggerUrl(token); } + copyText(event, text: string) { + copyToClipboard(text).then( + () => this.message.success($localize `:@@common.copy-success:Copied`) + ); + } + flagTriggerTypeLabel = { [FlagTriggerTypeEnum.FeatureFlagGeneral]: 'General' } diff --git a/modules/front-end/src/app/features/safe/feature-flags/details/experimentation/experimentation.component.html b/modules/front-end/src/app/features/safe/feature-flags/details/experimentation/experimentation.component.html index a60f984e2..225e0a0ea 100644 --- a/modules/front-end/src/app/features/safe/feature-flags/details/experimentation/experimentation.component.html +++ b/modules/front-end/src/app/features/safe/feature-flags/details/experimentation/experimentation.component.html @@ -58,7 +58,7 @@

Refresh

- +
    @@ -68,7 +68,6 @@

    nzPopconfirmTitle="This would remove all data of this experiment, it cannot be reverted, are you sure to remove it?" nzPopconfirmPlacement="bottomRight" [nzPopconfirmOverlayStyle]="{minWidth: '240px'}" - style="width: 100%;display: flex;justify-content: center;" (nzOnConfirm)="onDeleteExptDataClick(experiment)"> Remove data @@ -78,7 +77,6 @@

    nzPopconfirmTitle="This would remove the experiment and all data, it cannot be reverted, are you sure to remove it?" nzPopconfirmPlacement="bottomRight" [nzPopconfirmOverlayStyle]="{minWidth: '240px'}" - style="width: 100%;display: flex;justify-content: center;" (nzOnConfirm)="onDeleteExptClick(experiment)"> Remove experiment diff --git a/modules/front-end/src/app/features/safe/feature-flags/details/experimentation/experimentation.component.less b/modules/front-end/src/app/features/safe/feature-flags/details/experimentation/experimentation.component.less index 5bf414121..83d81a4fe 100644 --- a/modules/front-end/src/app/features/safe/feature-flags/details/experimentation/experimentation.component.less +++ b/modules/front-end/src/app/features/safe/feature-flags/details/experimentation/experimentation.component.less @@ -111,7 +111,7 @@ display: flex; .warning-icon { - color: #ff912b; + color: @yellow5-color; } .warning-content { padding-left: 5px; diff --git a/modules/front-end/src/app/features/safe/feature-flags/details/setting/setting.component.less b/modules/front-end/src/app/features/safe/feature-flags/details/setting/setting.component.less index 39760b01d..f474930a9 100644 --- a/modules/front-end/src/app/features/safe/feature-flags/details/setting/setting.component.less +++ b/modules/front-end/src/app/features/safe/feature-flags/details/setting/setting.component.less @@ -384,14 +384,14 @@ font-weight: 500; padding: 10px; border: 1px solid #d2d2d2; - border-left: 3px solid #ff912b; + border-left: 3px solid @yellow5-color; border-radius: 10px; .warning { display: flex; .warning-icon { - color: #ff912b; + color: @yellow5-color; } .warning-content { padding-left: 5px; diff --git a/modules/front-end/src/app/features/safe/feature-flags/index/index.component.less b/modules/front-end/src/app/features/safe/feature-flags/index/index.component.less index 8d595eae6..92ee3c71d 100644 --- a/modules/front-end/src/app/features/safe/feature-flags/index/index.component.less +++ b/modules/front-end/src/app/features/safe/feature-flags/index/index.component.less @@ -35,7 +35,7 @@ section.body-container { } .copy-icon { - margin-right: 4px; + margin-left: 0px; &:hover { cursor: pointer; diff --git a/modules/front-end/src/app/features/safe/iam/components/policy-editor/actions-selector/actions-selector.component.html b/modules/front-end/src/app/features/safe/iam/components/policy-editor/actions-selector/actions-selector.component.html index fb83f65e5..6b5d655bd 100644 --- a/modules/front-end/src/app/features/safe/iam/components/policy-editor/actions-selector/actions-selector.component.html +++ b/modules/front-end/src/app/features/safe/iam/components/policy-editor/actions-selector/actions-selector.component.html @@ -7,7 +7,7 @@
    -
    +
    {{act.displayName}} ({{act.name}})
    diff --git a/modules/front-end/src/app/features/safe/iam/components/policy-editor/actions-selector/actions-selector.component.ts b/modules/front-end/src/app/features/safe/iam/components/policy-editor/actions-selector/actions-selector.component.ts index 7e8b1d6c4..023b6433d 100644 --- a/modules/front-end/src/app/features/safe/iam/components/policy-editor/actions-selector/actions-selector.component.ts +++ b/modules/front-end/src/app/features/safe/iam/components/policy-editor/actions-selector/actions-selector.component.ts @@ -1,6 +1,6 @@ import {Component, EventEmitter, Input, Output, ViewChild} from "@angular/core"; import {NzSelectComponent} from "ng-zorro-antd/select"; -import {IamPolicyAction} from "@features//safe/iam/components/policy-editor/types"; +import {IamPolicyAction} from "@shared/policy"; @Component({ selector: 'actions-selector', diff --git a/modules/front-end/src/app/features/safe/iam/components/policy-editor/policy-editor.component.html b/modules/front-end/src/app/features/safe/iam/components/policy-editor/policy-editor.component.html index 15f996a64..eb3802b1f 100644 --- a/modules/front-end/src/app/features/safe/iam/components/policy-editor/policy-editor.component.html +++ b/modules/front-end/src/app/features/safe/iam/components/policy-editor/policy-editor.component.html @@ -45,8 +45,8 @@
    Allow or deny
    - - + +
    diff --git a/modules/front-end/src/app/features/safe/iam/components/policy-editor/policy-editor.component.ts b/modules/front-end/src/app/features/safe/iam/components/policy-editor/policy-editor.component.ts index 90d0dd75e..5a3528719 100644 --- a/modules/front-end/src/app/features/safe/iam/components/policy-editor/policy-editor.component.ts +++ b/modules/front-end/src/app/features/safe/iam/components/policy-editor/policy-editor.component.ts @@ -2,14 +2,14 @@ import {Component, EventEmitter, Input, Output} from '@angular/core'; import { EffectEnum, IamPolicyAction, + IPolicyStatement, + isResourceGeneral, permissionActions, Resource, - resourceActionsDict, resourcesTypes, - ResourceType, - ResourceTypeEnum -} from "@features/safe/iam/components/policy-editor/types"; + ResourceType +} from "@shared/policy"; import {deepCopy, encodeURIComponentFfc, uuidv4} from "@utils/index"; -import {IPolicy, IPolicyStatement} from "@features/safe/iam/types/policy"; +import {IPolicy} from "@features/safe/iam/types/policy"; import {NzMessageService} from "ng-zorro-antd/message"; import {PolicyService} from "@services/policy.service"; import {Router} from "@angular/router"; @@ -31,36 +31,30 @@ class PolicyStatementViewModel { this.resourceType = resourcesTypes.find(rt => rt.type === statement.resourceType) || null; this.effect = statement.effect === 'allow' ? EffectEnum.Allow : EffectEnum.Deny; - const allActions = Object.keys(resourceActionsDict).flatMap(p => resourceActionsDict[p]); + const allActions = [...Object.values(permissionActions)]; this.selectedActions = statement.actions.map(act => { const find = allActions.find(a => act === a.name); return find || act as unknown as IamPolicyAction; }); - this.selectedResources = statement.resources.map(rsc => ({id: uuidv4(), name: rsc, rn: rsc})); + this.selectedResources = statement.resources.map(rsc => ({id: uuidv4(), name: rsc, rn: rsc, type: this.resourceType.type})); + + // All the resources here are the same type, and if it's general type, resources only contains one element + const isGeneralResource = isResourceGeneral(this.resourceType?.type, statement.resources[0]); + this.availableActions = [...Object.values(permissionActions)].filter((rs) => rs.resourceType === this.resourceType?.type && (isGeneralResource || rs.isSpecificApplicable)); } else { this.id = uuidv4(); this.effect = EffectEnum.Allow; this.selectedActions = []; this.selectedResources = []; + this.availableActions =[]; } - - const actionKey = this.resourceType?.type === ResourceTypeEnum.General ? - `${ResourceTypeEnum.General},${this.selectedResources[0]?.rn}` : - this.resourceType?.type; - - this.availableActions = resourceActionsDict[actionKey]; } onResourceTypeChange(){ this.selectedActions = []; this.selectedResources = []; - - const actionKey = this.resourceType?.type === ResourceTypeEnum.General ? - `${ResourceTypeEnum.General},${this.selectedResources[0]?.rn}` : - this.resourceType?.type; - - this.availableActions = resourceActionsDict[actionKey]; + this.availableActions = []; } selectedActions: IamPolicyAction[] = []; @@ -71,14 +65,10 @@ class PolicyStatementViewModel { selectedResources: Resource[] = []; onSelectedResourcesChange(resources: Resource[]) { this.selectedResources = [...resources]; + // All the resources here are the same type, and if it's general type, resources only contains one element + const isGeneralResource = isResourceGeneral(resources[0].type, resources[0].rn); - let actionKey: string = this.resourceType?.type; - if (this.resourceType?.type === ResourceTypeEnum.General) { - this.selectedActions = []; - actionKey = `${ResourceTypeEnum.General},${this.selectedResources[0]?.rn}`; - } - - this.availableActions = resourceActionsDict[actionKey]; + this.availableActions = [...Object.values(permissionActions)].filter((rs) => rs.resourceType === this.resourceType?.type && (isGeneralResource || rs.isSpecificApplicable)); } getOutput(): IPolicyStatement { diff --git a/modules/front-end/src/app/features/safe/iam/components/policy-editor/resources-selector/resources-selector.component.ts b/modules/front-end/src/app/features/safe/iam/components/policy-editor/resources-selector/resources-selector.component.ts index aa6f7c70c..dc3b1e141 100644 --- a/modules/front-end/src/app/features/safe/iam/components/policy-editor/resources-selector/resources-selector.component.ts +++ b/modules/front-end/src/app/features/safe/iam/components/policy-editor/resources-selector/resources-selector.component.ts @@ -1,15 +1,15 @@ -import {Component, EventEmitter, Input, Output, ViewChild} from "@angular/core"; -import {NzSelectComponent} from "ng-zorro-antd/select"; +import { Component, EventEmitter, Input, Output, ViewChild } from "@angular/core"; +import { NzSelectComponent } from "ng-zorro-antd/select"; import { + isResourceGeneral, Resource, ResourceParamViewModel, ResourceType, - ResourceTypeEnum, RNViewModel, rscParamsDict -} from "@features/safe/iam/components/policy-editor/types"; -import {ResourceService} from "@services/resource.service"; -import {deepCopy} from "@utils/index"; +} from "@shared/policy"; +import { ResourceService } from "@services/resource.service"; +import { deepCopy } from "@utils/index"; @Component({ selector: 'resources-selector', @@ -44,8 +44,10 @@ export class ResourcesSelectorComponent { @Input() selectedResources: Resource[] = []; onResourceChange() { - if (this.resourceType.type === ResourceTypeEnum.General) { + if (isResourceGeneral(this.resourceSelectModel.type, this.resourceSelectModel.rn)) { this.selectedResources = []; + } else { + this.selectedResources = this.selectedResources.filter((r) => !isResourceGeneral(r.type, r.rn)) } this.selectedResources = [...this.selectedResources, {...this.resourceSelectModel}]; this.onSelectedResourcesChange.next(this.selectedResources); @@ -88,14 +90,14 @@ export class ResourcesSelectorComponent { const paramValues = rsc.rn.split(':') .map(r => { const part = r.split('/'); - return {type: part[0], val: part[1]}}) - .reduce((acc, cur) => { - acc[cur.type] = cur.val; + return {type: part[0], val: part[1], isAnyChecked: part[1] === '*' }}) + .reduce((acc, { type, val, isAnyChecked}) => { + acc[type] = { val: val, isAnyChecked }; return acc; }, {}); if (paramValues) { - this.rscParams = this.rscParams.map(p => ({...p, val: paramValues[p.resourceType]})); + this.rscParams = this.rscParams.map(p => ({...p, val: paramValues[p.resourceType].val, isAnyChecked: paramValues[p.resourceType].isAnyChecked})); } } diff --git a/modules/front-end/src/app/features/safe/iam/components/policy-editor/types.ts b/modules/front-end/src/app/features/safe/iam/components/policy-editor/types.ts deleted file mode 100644 index 6c04d785b..000000000 --- a/modules/front-end/src/app/features/safe/iam/components/policy-editor/types.ts +++ /dev/null @@ -1,273 +0,0 @@ -import {generalResourceRNPattern, permissionActions} from "@shared/permissions"; -import {uuidv4} from "@utils/index"; - -export interface Resource { - id: string; - name: string; - rn: string; -} - -export interface ValPlaceholder { - displayName: string, - name: string -} - -export interface IamPolicyAction { - id: string; - name: string; - displayName: string; -} - -export enum ResourceTypeEnum { - All = '*', - General = 'general', - Project = 'project', - Env = 'env', -} - -export enum EffectEnum { - Allow = 'allow', - Deny = 'deny' -} - -export interface ResourceType { - type: ResourceTypeEnum, - pattern: string, - displayName: string -} - -export interface RNViewModel { - val: string; - id: string; - isInvalid?: boolean -} - -export const resourcesTypes: ResourceType[] = [ - { - type: ResourceTypeEnum.All, - pattern: generalResourceRNPattern.all, - displayName: $localize`:@@iam.rsc-type.all:All` - }, - { - type: ResourceTypeEnum.General, - pattern: generalResourceRNPattern.project, - displayName: $localize`:@@iam.rsc-type.general:General` - }, - { - type: ResourceTypeEnum.Project, - pattern: 'project/{project}', - displayName: $localize`:@@iam.rsc-type.project:Project` - }, - { - type: ResourceTypeEnum.Env, - pattern: 'project/{project}:env/{env}', - displayName: $localize`:@@iam.rsc-type.env:Environment` - } -]; - -export interface ResourceParamViewModel { - val: string; - resourceType: string; - placeholder: ValPlaceholder; - isAnyChecked: boolean; - isInvalid: boolean -} - -export const rscParamsDict: {[key in ResourceTypeEnum]: ResourceParamViewModel[]} = { - [ResourceTypeEnum.All]: [], - [ResourceTypeEnum.General]: [], - [ResourceTypeEnum.Project]: [ - { - val: '', - resourceType: ResourceTypeEnum.Project, - placeholder: { - name: '{project}', - displayName: $localize`:@@iam.policy.project:Project` - }, - isAnyChecked: false, - isInvalid: false - } - ], - [ResourceTypeEnum.Env]: [ - { - val: '', - resourceType: 'project', - placeholder: { - name: '{project}', - displayName: $localize`:@@iam.policy.project:Project` - }, - isAnyChecked: false, - isInvalid: false - }, - { - val: '', - resourceType: 'env', - placeholder: { - name: '{env}', - displayName: $localize`:@@iam.policy.environment:Environment` - }, - isAnyChecked: false, - isInvalid: false - } - ], -}; - -export const resourceActionsDict: {[key: string]: IamPolicyAction[]} = { - [ResourceTypeEnum.All]: [ - { - id: uuidv4(), - name: permissionActions.All, - displayName: $localize`:@@iam.action.all:All` - }, - ], - [`${ResourceTypeEnum.General},account`]: [ - { - id: uuidv4(), - name: permissionActions.UpdateOrgName, - displayName: $localize`:@@iam.action.update-org-name:Update org name` - }, - ], - [`${ResourceTypeEnum.General},iam`]: [ - { - id: uuidv4(), - name: permissionActions.CanManageIAM, - displayName: $localize`:@@iam.action.iam:IAM` - }, - ], - [`${ResourceTypeEnum.General},project`]: [ // for all projects - { - id: uuidv4(), - name: permissionActions.ListProjects, - displayName: $localize`:@@iam.action.list-projects:List projects` - }, - { - id: uuidv4(), - name: permissionActions.CreateProject, - displayName: $localize`:@@iam.action.create-projects:Create projects` - }, - { - id: uuidv4(), - name: permissionActions.DeleteProject, - displayName: $localize`:@@iam.action.delete-projects:Delete projects` - }, - { - id: uuidv4(), - name: permissionActions.UpdateProjectSettings, - displayName: $localize`:@@iam.action.update-project-settings:Update project settings` - }, - { - id: uuidv4(), - name: permissionActions.ListEnvs, - displayName: $localize`:@@iam.action.list-envs:List environments` - }, - { - id: uuidv4(), - name: permissionActions.CreateEnv, - displayName: $localize`:@@iam.action.create-env:Create environment` - }, - { - id: uuidv4(), - name: permissionActions.AccessEnvs, - displayName: $localize`:@@iam.action.access-envs:Access environments` - }, - { - id: uuidv4(), - name: permissionActions.DeleteEnv, - displayName: $localize`:@@iam.action.delete-envs:Delete environments` - }, - { - id: uuidv4(), - name: permissionActions.UpdateEnvSettings, - displayName: $localize`:@@iam.action.update-env-settings:Update environment settings` - }, - { - id: uuidv4(), - name: permissionActions.DeleteEnvSecret, - displayName: $localize`:@@iam.action.delete-env-secret:Delete environment secret` - }, - { - id: uuidv4(), - name: permissionActions.CreateEnvSecret, - displayName: $localize`:@@iam.action.create-env-secret:Create environment secret` - }, - { - id: uuidv4(), - name: permissionActions.UpdateEnvSecret, - displayName: $localize`:@@iam.action.update-env-secret:Update environment secret` - }, - ], - [ResourceTypeEnum.Project]: [ // for a specific project - { - id: uuidv4(), - name: permissionActions.AccessEnvs, - displayName: $localize`:@@iam.action.access-envs:Access environments` - }, - { - id: uuidv4(), - name: permissionActions.DeleteProject, - displayName: $localize`:@@iam.action.delete-projects:Delete projects` - }, - { - id: uuidv4(), - name: permissionActions.UpdateProjectSettings, - displayName: $localize`:@@iam.action.update-project-settings:Update project settings` - }, - { - id: uuidv4(), - name: permissionActions.ListEnvs, - displayName: $localize`:@@iam.action.list-envs:List environments` - }, - { - id: uuidv4(), - name: permissionActions.CreateEnv, - displayName: $localize`:@@iam.action.create-env:Create environment` - }, - { - id: uuidv4(), - name: permissionActions.DeleteEnvSecret, - displayName: $localize`:@@iam.action.delete-env-secret:Delete environment secret` - }, - { - id: uuidv4(), - name: permissionActions.CreateEnvSecret, - displayName: $localize`:@@iam.action.create-env-secret:Create environment secret` - }, - { - id: uuidv4(), - name: permissionActions.UpdateEnvSecret, - displayName: $localize`:@@iam.action.update-env-secret:Update environment secret` - }, - ], - [ResourceTypeEnum.Env]: [ // for a specific environment - { - id: uuidv4(), - name: permissionActions.AccessEnvs, - displayName: $localize`:@@iam.action.access-envs:Access environments` - }, - { - id: uuidv4(), - name: permissionActions.DeleteEnv, - displayName: $localize`:@@iam.action.delete-envs:Delete environments` - }, - { - id: uuidv4(), - name: permissionActions.UpdateEnvSettings, - displayName: $localize`:@@iam.action.update-env-settings:Update environment settings` - }, - { - id: uuidv4(), - name: permissionActions.DeleteEnvSecret, - displayName: $localize`:@@iam.action.delete-env-secret:Delete environment secret` - }, - { - id: uuidv4(), - name: permissionActions.CreateEnvSecret, - displayName: $localize`:@@iam.action.create-env-secret:Create environment secret` - }, - { - id: uuidv4(), - name: permissionActions.UpdateEnvSecret, - displayName: $localize`:@@iam.action.update-env-secret:Update environment secret` - }, - ] -} diff --git a/modules/front-end/src/app/features/safe/iam/policies/details/permission/permission.component.ts b/modules/front-end/src/app/features/safe/iam/policies/details/permission/permission.component.ts index 35e30a40e..55724f5a4 100644 --- a/modules/front-end/src/app/features/safe/iam/policies/details/permission/permission.component.ts +++ b/modules/front-end/src/app/features/safe/iam/policies/details/permission/permission.component.ts @@ -1,8 +1,9 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { IPolicy, IPolicyStatement } from "@features/safe/iam/types/policy"; +import { IPolicy } from "@features/safe/iam/types/policy"; import { PolicyService } from "@services/policy.service"; import { NzMessageService } from "ng-zorro-antd/message"; +import { IPolicyStatement } from "@shared/policy"; @Component({ selector: 'permission', diff --git a/modules/front-end/src/app/features/safe/iam/types/policy.ts b/modules/front-end/src/app/features/safe/iam/types/policy.ts index f89ac317c..3b515b3e6 100644 --- a/modules/front-end/src/app/features/safe/iam/types/policy.ts +++ b/modules/front-end/src/app/features/safe/iam/types/policy.ts @@ -1,3 +1,5 @@ +import { IPolicyStatement } from "@shared/policy"; + export interface IPolicy { id: string; type: string; @@ -10,14 +12,6 @@ export interface IPolicy { resourceName?: string; } -export interface IPolicyStatement { - id: string; - resourceType: string; - effect: string; - actions: string[]; - resources: string[]; -} - export class PolicyFilter { name?: string; pageIndex: number; diff --git a/modules/front-end/src/app/features/safe/integrations/access-tokens/access-tokens-routing.module.ts b/modules/front-end/src/app/features/safe/integrations/access-tokens/access-tokens-routing.module.ts new file mode 100644 index 000000000..fd5157698 --- /dev/null +++ b/modules/front-end/src/app/features/safe/integrations/access-tokens/access-tokens-routing.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { AccessTokensComponent } from "@features/safe/integrations/access-tokens/access-tokens.component"; + +const routes: Routes = [ + { + path: '', + component: AccessTokensComponent, + children: [ + { + path: '', + loadChildren: () => import("./index/index.module").then(m => m.IndexModule), + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + ] +}) +export class AccessTokensRoutingModule { } diff --git a/modules/front-end/src/app/features/safe/integrations/access-tokens/access-tokens.component.ts b/modules/front-end/src/app/features/safe/integrations/access-tokens/access-tokens.component.ts new file mode 100644 index 000000000..cee7bf4c5 --- /dev/null +++ b/modules/front-end/src/app/features/safe/integrations/access-tokens/access-tokens.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'integrations-access-tokens', + template: `` +}) +export class AccessTokensComponent { + constructor( + ) { + } +} diff --git a/modules/front-end/src/app/features/safe/integrations/access-tokens/access-tokens.module.ts b/modules/front-end/src/app/features/safe/integrations/access-tokens/access-tokens.module.ts new file mode 100644 index 000000000..ced67357b --- /dev/null +++ b/modules/front-end/src/app/features/safe/integrations/access-tokens/access-tokens.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { AccessTokensRoutingModule } from './access-tokens-routing.module'; +import { CommonModule } from '@angular/common'; +import { AccessTokensComponent } from "@features/safe/integrations/access-tokens/access-tokens.component"; + +@NgModule({ + declarations: [ + AccessTokensComponent + ], + imports: [ + CommonModule, + AccessTokensRoutingModule + ], + providers: [ + ] +}) +export class AccessTokensModule { } diff --git a/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index-routing.module.ts b/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index-routing.module.ts new file mode 100644 index 000000000..95bd2240a --- /dev/null +++ b/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index-routing.module.ts @@ -0,0 +1,19 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import {IndexComponent} from "@features/safe/integrations/access-tokens/index/index.component"; + + +const routes: Routes = [ + { + path: '', + component: IndexComponent, + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + ] +}) +export class IndexRoutingModule { } diff --git a/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.component.html b/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.component.html new file mode 100644 index 000000000..cd5851c50 --- /dev/null +++ b/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.component.html @@ -0,0 +1,133 @@ +
    +
    +
    + + + + + + + + + + + + + + Loading... + + + + + + +
    + + +
    +
    + + + + Name + Type + Created by + Status + Last used + Access token + Actions + + + + + {{ item.name }} + + {{item.type | accessTokenType}} + + + {{ item.creator.name }} ({{ item.creator.email }}) + {{ item.creator.email }} + + + + + + + + + {{ item.status | accessTokenStatus }} + + {{ item.lastUsedAt | date: 'YYYY-MM-dd HH:mm'}} + {{ item.token }} + + + + + +
      +
    • + Edit +
    • +
    • + Activate +
    • +
    • + Deactivate +
    • +
    • + Remove +
    • +
    +
    + + + +
    + + + +
    +
    + + + diff --git a/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.component.less b/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.component.less new file mode 100644 index 000000000..112bb4460 --- /dev/null +++ b/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.component.less @@ -0,0 +1,33 @@ +@import "variables"; + +.table-content-area { + + .search-inputs { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 16px; + } + + nz-input-group { + width: 300px; + } + + .copy-icon { + color: @grey50-color; + &:hover { + cursor: pointer; + color: @grey70-color; + } + } + + .status { + &.status-Active { + color: @green70-color; + } + } +} + + + + diff --git a/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.component.ts b/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.component.ts new file mode 100644 index 000000000..d6bdad500 --- /dev/null +++ b/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.component.ts @@ -0,0 +1,158 @@ +import { Component, OnInit } from '@angular/core'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; +import { Router } from "@angular/router"; +import { NzMessageService } from "ng-zorro-antd/message"; +import { + AccessTokenFilter, AccessTokenStatusEnum, AccessTokenTypeEnum, + IAccessToken, + IPagedAccessToken +} from "@features/safe/integrations/access-tokens/types/access-token"; +import { AccessTokenService } from "@services/access-token.service"; +import { IOrganization } from "@shared/types"; +import { CURRENT_ORGANIZATION } from "@utils/localstorage-keys"; +import { TeamService } from "@services/team.service"; +import { PermissionsService } from "@services/permissions.service"; +import { generalResourceRNPattern, permissionActions } from "@shared/policy"; + +@Component({ + selector: 'access-tokens', + templateUrl: './index.component.html', + styleUrls: ['./index.component.less'] +}) +export class IndexComponent implements OnInit { + + AccessTokenStatusActive = AccessTokenStatusEnum.Active; + AccessTokenStatusInactive = AccessTokenStatusEnum.Inactive; + + AccessTokenTypePersonal = AccessTokenTypeEnum.Personal; + AccessTokenTypeService = AccessTokenTypeEnum.Service; + + canTakeActionOnPersonalAccessToken = false; + canTakeActionOnServiceAccessToken = false; + + constructor( + private router: Router, + private message: NzMessageService, + private teamService: TeamService, + private permissionsService: PermissionsService, + private accessTokenService: AccessTokenService + ) { + const currentAccount: IOrganization = JSON.parse(localStorage.getItem(CURRENT_ORGANIZATION())); + + this.creatorSearchChange$.pipe( + debounceTime(500) + ).subscribe(searchText => { + this.teamService.searchMembers(currentAccount.id, searchText).subscribe({ + next:(result) => { + this.creatorList = result.items; + this.isCreatorsLoading = false; + }, + error: _ => { + this.isCreatorsLoading = false; + } + }); + }); + + this.canTakeActionOnPersonalAccessToken = this.permissionsService.canTakeAction(generalResourceRNPattern.accessToken, permissionActions.ManagePersonalAccessTokens); + this.canTakeActionOnServiceAccessToken = this.permissionsService.canTakeAction(generalResourceRNPattern.accessToken, permissionActions.ManageServiceAccessTokens); + } + + private search$ = new Subject(); + + creatorSearchChange$ = new BehaviorSubject(''); + isCreatorsLoading = false; + creatorList: any[]; + onSearchCreators(value: string) { + if (value.length > 0) { + this.isCreatorsLoading = true; + this.creatorSearchChange$.next(value); + } + } + + isLoading: boolean = true; + filter: AccessTokenFilter = new AccessTokenFilter(); + accessTokens: IPagedAccessToken = { + items: [], + totalCount: 0 + }; + + ngOnInit(): void { + this.search$.pipe( + debounceTime(300) + ).subscribe(() => { + this.getAccessTokens(); + }); + + this.search$.next(null); + } + + getAccessTokens() { + this.isLoading = true; + this.accessTokenService.getList(this.filter).subscribe({ + next: (accessTokens) => { + this.accessTokens = accessTokens; + + this.isLoading = false; + }, + error:() => this.isLoading = false + }); + } + + doSearch(resetPage?: boolean) { + if (resetPage) { + this.filter.pageIndex = 1; + } + + this.search$.next(null); + } + + accessTokenDrawerVisible: boolean = false; + + accessTokenDrawerClosed(data: any) { //{ isEditing: boolean, id: string, name: string } + this.accessTokenDrawerVisible = false; + + if (!data) { + return; + } + + if (!data.isEditing) { + this.getAccessTokens(); + } else { + this.accessTokens.items = this.accessTokens.items.map((ac) => { + if (ac.id === data.id) { + return { ...ac, name: data.name }; + } + + return ac; + }) + } + } + + currentAccessToken: IAccessToken = { name: null, type: AccessTokenTypeEnum.Personal, permissions: []}; + creatOrEdit(accessToken: IAccessToken = { name: null, type: AccessTokenTypeEnum.Personal, permissions: []}) { + this.currentAccessToken = accessToken; + this.accessTokenDrawerVisible = true; + } + + delete(accessToken: IAccessToken) { + this.accessTokenService.delete(accessToken.id).subscribe({ + next:() => { + this.message.success($localize `:@@common.operation-success:Operation succeeded`); + this.accessTokens.items = this.accessTokens.items.filter(it => it.id !== accessToken.id); + this.accessTokens.totalCount--; + }, + error: () => this.message.error($localize `:@@common.operation-failed:Operation failed`) + }) + } + + toggleStatus(accessToken: IAccessToken) { + this.accessTokenService.toggleStatus(accessToken.id).subscribe({ + next:() => { + this.message.success($localize `:@@common.operation-success:Operation succeeded`); + accessToken.status = accessToken.status === this.AccessTokenStatusActive ? this.AccessTokenStatusInactive : this.AccessTokenStatusActive; + }, + error: () => this.message.error($localize `:@@common.operation-failed:Operation failed`) + }) + } +} diff --git a/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.module.ts b/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.module.ts new file mode 100644 index 000000000..9c0ac2502 --- /dev/null +++ b/modules/front-end/src/app/features/safe/integrations/access-tokens/index/index.module.ts @@ -0,0 +1,48 @@ +import { NgModule } from '@angular/core'; +import { IndexRoutingModule } from './index-routing.module'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { NzSpinModule } from 'ng-zorro-antd/spin'; +import { NzSelectModule } from 'ng-zorro-antd/select'; +import { NzEmptyModule } from 'ng-zorro-antd/empty'; +import { NzTableModule } from 'ng-zorro-antd/table'; +import { NzButtonModule } from 'ng-zorro-antd/button'; +import { NzIconModule } from 'ng-zorro-antd/icon'; +import { NzInputModule } from 'ng-zorro-antd/input'; +import { NzMessageModule } from 'ng-zorro-antd/message'; + +import { NzDropDownModule } from 'ng-zorro-antd/dropdown'; +import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; +import {IndexComponent} from "@features/safe/integrations/access-tokens/index/index.component"; +import {NzDividerModule} from "ng-zorro-antd/divider"; +import {NzPopconfirmModule} from "ng-zorro-antd/popconfirm"; +import {CoreModule} from "@core/core.module"; + + +@NgModule({ + declarations: [ + IndexComponent + ], + imports: [ + CommonModule, + FormsModule, + NzSpinModule, + NzSelectModule, + NzEmptyModule, + NzTableModule, + NzButtonModule, + NzIconModule, + NzInputModule, + CoreModule, + NzMessageModule, + NzDropDownModule, + NzToolTipModule, + NzDividerModule, + NzPopconfirmModule, + IndexRoutingModule, + CoreModule + ], + providers: [ + ] +}) +export class IndexModule { } diff --git a/modules/front-end/src/app/features/safe/integrations/access-tokens/types/access-token.ts b/modules/front-end/src/app/features/safe/integrations/access-tokens/types/access-token.ts new file mode 100644 index 000000000..497bb1db5 --- /dev/null +++ b/modules/front-end/src/app/features/safe/integrations/access-tokens/types/access-token.ts @@ -0,0 +1,53 @@ +import { IPolicy } from "@features/safe/iam/types/policy"; +import { IMember } from "@features/safe/iam/types/member"; +import { IPolicyStatement } from "@shared/policy"; + +export interface IAccessToken { + id?: string; + type: string; + creator?: IMember; + status?: string; + token?: string; + name: string; + policies?: IPolicy[], + permissions?: IPolicyStatement[], + lastUsedAt?: string +} + +export enum AccessTokenTypeEnum { + Personal = 'Personal', + Service = 'Service' +} + +export enum AccessTokenStatusEnum { + Active = 'Active', + Inactive = 'Inactive' +} + +export interface IAccessTokenPolicy extends IPolicy { + isSelected: boolean +} + +export class AccessTokenFilter { + name?: string; + creatorId?: string; + type?: string; + pageIndex: number; + pageSize: number; + + constructor( + name?: string, + creator?: string, + type?: string, + pageIndex: number = 1, + pageSize: number = 10) { + this.name = name ?? ''; + this.pageIndex = pageIndex; + this.pageSize = pageSize; + } +} + +export interface IPagedAccessToken { + totalCount: number; + items: IAccessToken[]; +} diff --git a/modules/front-end/src/app/features/safe/integrations/access-tokens/types/permission-helper.ts b/modules/front-end/src/app/features/safe/integrations/access-tokens/types/permission-helper.ts new file mode 100644 index 000000000..3b7119a77 --- /dev/null +++ b/modules/front-end/src/app/features/safe/integrations/access-tokens/types/permission-helper.ts @@ -0,0 +1,52 @@ +import { IamPolicyAction, IPolicyStatement, permissionActions } from "@shared/policy"; +import { uuidv4 } from "@utils/index"; + +export interface IPermissionStatementGroup { + allChecked: boolean, + indeterminate: boolean, + statements: IPermissionStatement[] +} + +export interface IPermissionStatement extends IPolicyStatement { + action: IamPolicyAction; + checked: boolean +} + +export const preProcessPermissions = (statements: IPolicyStatement[]): { [key: string]: IPermissionStatementGroup} => { + return statements.flatMap((statement) => { + const {effect, resourceType, resources} = statement; + return statement.actions.map((action) => ({ + effect, + resourceType, + resources, + action: permissionActions[action] + })); + }).filter(({effect, resourceType, resources, action}) => action && action.isOpenAPIApplicable) + .reduce((acc, cur) => { + acc[cur.resourceType] = acc[cur.resourceType] || { allChecked: true, indeterminate: false, statements: [] }; + const idx = acc[cur.resourceType].statements.findIndex((api) => api.effect ===cur.effect && api.action.name ===cur.action.name && api.effect === 'allow'); + + if (idx !== -1) { // duplicate exists + const statement = acc[cur.resourceType].statements[idx]; + const resources = [...statement.resources, ...cur.resources]; + acc[cur.resourceType].statements.splice(idx, 1, { ...cur, checked: true, resources: resources.filter((resource, idx) => resources.indexOf(resource) === idx)}); + } else { + acc[cur.resourceType].statements.push({...cur, checked: true}); + } + + return acc; + }, {}); +} + +export const postProcessPermissions = (permissions: { [key: string]: IPermissionStatementGroup}): IPolicyStatement[] => { + return Object.keys(permissions) + .flatMap((property) => permissions[property].statements) + .filter((statement) => statement.checked) + .map(({id, effect, resourceType, resources, action}) => ({ + id: uuidv4(), + effect, + resourceType, + resources, + actions: [action.name] + })); +} diff --git a/modules/front-end/src/app/features/safe/integrations/integrations-routing.module.ts b/modules/front-end/src/app/features/safe/integrations/integrations-routing.module.ts new file mode 100644 index 000000000..5fb59285d --- /dev/null +++ b/modules/front-end/src/app/features/safe/integrations/integrations-routing.module.ts @@ -0,0 +1,27 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { IntegrationsComponent } from './integrations.component'; +import { AccessTokensGuard } from "@core/guards/accessTokens.guard"; + +const routes: Routes = [ + { + path: '', + component: IntegrationsComponent, + children: [ + { + path: 'access-tokens', + canActivate: [AccessTokensGuard], + loadChildren: () => import("./access-tokens/access-tokens.module").then(m => m.AccessTokensModule), + data: { + breadcrumb: $localize `:@@integrations.routing.access-tokens:Access tokens` + }, + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class IntegrationsRoutingModule { } diff --git a/modules/front-end/src/app/features/safe/integrations/integrations.component.html b/modules/front-end/src/app/features/safe/integrations/integrations.component.html new file mode 100644 index 000000000..bf4a1a44d --- /dev/null +++ b/modules/front-end/src/app/features/safe/integrations/integrations.component.html @@ -0,0 +1,4 @@ + + + + diff --git a/modules/front-end/src/app/features/safe/integrations/integrations.component.less b/modules/front-end/src/app/features/safe/integrations/integrations.component.less new file mode 100644 index 000000000..e69de29bb diff --git a/modules/front-end/src/app/features/safe/integrations/integrations.component.ts b/modules/front-end/src/app/features/safe/integrations/integrations.component.ts new file mode 100644 index 000000000..7312b41aa --- /dev/null +++ b/modules/front-end/src/app/features/safe/integrations/integrations.component.ts @@ -0,0 +1,16 @@ +import { Component, OnDestroy } from '@angular/core'; + +@Component({ + selector: 'integrations', + templateUrl: `./integrations.component.html`, + styleUrls: ['./integrations.component.less'] +}) +export class IntegrationsComponent implements OnDestroy { + + constructor( + ) { + } + + ngOnDestroy(): void { + } +} diff --git a/modules/front-end/src/app/features/safe/integrations/integrations.module.ts b/modules/front-end/src/app/features/safe/integrations/integrations.module.ts new file mode 100644 index 000000000..317754930 --- /dev/null +++ b/modules/front-end/src/app/features/safe/integrations/integrations.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { IntegrationsRoutingModule } from './integrations-routing.module'; +import { CommonModule } from '@angular/common'; +import { IntegrationsComponent } from './integrations.component'; +import { NzResultModule } from 'ng-zorro-antd/result'; + +@NgModule({ + declarations: [ + IntegrationsComponent + ], + imports: [ + CommonModule, + NzResultModule, + IntegrationsRoutingModule, + ], + exports: [ + ], + providers: [] +}) +export class IntegrationsModule { } diff --git a/modules/front-end/src/app/features/safe/organizations/organization/organization.component.ts b/modules/front-end/src/app/features/safe/organizations/organization/organization.component.ts index 684034bf5..13336a2b7 100644 --- a/modules/front-end/src/app/features/safe/organizations/organization/organization.component.ts +++ b/modules/front-end/src/app/features/safe/organizations/organization/organization.component.ts @@ -6,7 +6,7 @@ import { IOrganization } from '@shared/types'; import { OrganizationService } from '@services/organization.service'; import { getCurrentOrganization } from "@utils/project-env"; import {PermissionsService} from "@services/permissions.service"; -import {generalResourceRNPattern, permissionActions} from "@shared/permissions"; +import {generalResourceRNPattern, permissionActions} from "@shared/policy"; import { MessageQueueService } from '@core/services/message-queue.service'; @Component({ diff --git a/modules/front-end/src/app/features/safe/organizations/project/project.component.html b/modules/front-end/src/app/features/safe/organizations/project/project.component.html index 9fc0332c9..bdb92a137 100644 --- a/modules/front-end/src/app/features/safe/organizations/project/project.component.html +++ b/modules/front-end/src/app/features/safe/organizations/project/project.component.html @@ -15,7 +15,7 @@
    - +
    @@ -23,7 +23,7 @@
    - Environment name + Name Current
    {{env.name}}
    @@ -33,7 +33,7 @@
    {{env.key}}
    -
    Description
    +
    Description
    {{env.description}}
    @@ -91,8 +91,11 @@
    - + + {{project.name}} Current + +