diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..da455b6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: ci + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: bufbuild/buf-setup-action@v1 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + - name: Restore dependencies + run: dotnet restore + - name: Format + run: dotnet format --verify-no-changes + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build --verbosity normal + - uses: bufbuild/buf-lint-action@v1 diff --git a/.github/workflows/grpc.yml b/.github/workflows/grpc.yml new file mode 100644 index 0000000..694752e --- /dev/null +++ b/.github/workflows/grpc.yml @@ -0,0 +1,27 @@ +name: grpc + +on: + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: bufbuild/buf-setup-action@v1 + + - name: Generate + run: buf generate SSTAlumniAssociation.Api + + - name: Push TS code + uses: cpina/github-action-push-to-another-repository@main + env: + SSH_DEPLOY_KEY: ${{ secrets.GRPC_CLIENT_TYPESCRIPT_DEPLOY_KEY }} + with: + source-directory: gen/es/Protos + destination-github-username: 'sstalumniassociation' + destination-repository-name: 'grpc-client-typescript' + user-email: bot@sstaa.org + target-branch: main diff --git a/Api/Api.csproj b/Api/Api.csproj deleted file mode 100644 index 4edeb20..0000000 --- a/Api/Api.csproj +++ /dev/null @@ -1,45 +0,0 @@ - - - - net8.0 - enable - enable - true - Linux - true - true - - - - - .dockerignore - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - diff --git a/Api/Authorization/AuthorizeMembersAttribute.cs b/Api/Authorization/AuthorizeMembersAttribute.cs deleted file mode 100644 index 8fbd4a7..0000000 --- a/Api/Authorization/AuthorizeMembersAttribute.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Api.Entities; -using Microsoft.AspNetCore.Authorization; - -namespace Api.Authorization; - -public class AuthorizeMembersAttribute: AuthorizeAttribute -{ - public AuthorizeMembersAttribute(params UserMemberType[] memberTypes) - { - Roles = string.Join(",", memberTypes); - } -} \ No newline at end of file diff --git a/Api/Authorization/ClaimsTransformation.cs b/Api/Authorization/ClaimsTransformation.cs deleted file mode 100644 index 803b62b..0000000 --- a/Api/Authorization/ClaimsTransformation.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Security.Claims; -using Api.Context; -using Microsoft.AspNetCore.Authentication; -using Microsoft.EntityFrameworkCore; - -namespace Api.Authorization; - -public class ClaimsTransformation(AppDbContext dbContext): IClaimsTransformation -{ - public async Task TransformAsync(ClaimsPrincipal principal) - { - var sub = principal.Claims.SingleOrDefault(c => c.Type == "user_id"); - if (sub is null) - { - return principal; - } - - var user = await dbContext.Users.SingleOrDefaultAsync(u => u.FirebaseId == sub.Value); - if (user is null) - { - return principal; - } - - var ci = new ClaimsIdentity(); - ci.AddClaim(new Claim(ClaimTypes.Role, user.MemberType.ToString())); - - principal.AddIdentity(ci); - - return principal; - } -} \ No newline at end of file diff --git a/Api/Context/AppDbContext.cs b/Api/Context/AppDbContext.cs deleted file mode 100644 index 02c537f..0000000 --- a/Api/Context/AppDbContext.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Api.Entities; -using Microsoft.EntityFrameworkCore; - -namespace Api.Context; - -public partial class AppDbContext : DbContext -{ - public AppDbContext(DbContextOptions options) - : base(options) - { - } - - public DbSet Users { get; set; } - public DbSet Events { get; set; } - public DbSet Articles { get; set; } - public DbSet UserEvents { get; set; } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - OnModelCreatingPartial(modelBuilder); - } - - partial void OnModelCreatingPartial(ModelBuilder modelBuilder); -} diff --git a/Api/Entities/UserMemberType.cs b/Api/Entities/UserMemberType.cs deleted file mode 100644 index 4a04cf7..0000000 --- a/Api/Entities/UserMemberType.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Api.Entities; - -public enum UserMemberType -{ - Exco = 0, - Associate = 1, - Affiliate = 2, - Ordinary = 3, - Revoked = 4, - ServiceAccount = 5 -} diff --git a/Api/Extensions/User.cs b/Api/Extensions/User.cs deleted file mode 100644 index 44fec70..0000000 --- a/Api/Extensions/User.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Api.Extensions; - -public static class UserExtensions -{ - public static User.V1.UserMemberType ToGrpcUserMemberType(this Entities.UserMemberType memberType) - { - return Enum.Parse(memberType.ToString()); - } - - public static User.V1.User ToGrpcUser(this Entities.User user) - { - return new User.V1.User - { - Id = user.Id.ToString(), - MemberId = user.MemberId, - MemberType = user.MemberType.ToGrpcUserMemberType(), - Name = user.Name, - Email = user.Email, - GraduationYear = user.GraduationYear, - }; - } -} \ No newline at end of file diff --git a/Api/Migrations/20240120163000_InitialCreate.Designer.cs b/Api/Migrations/20240120163000_InitialCreate.Designer.cs deleted file mode 100644 index caaaf7e..0000000 --- a/Api/Migrations/20240120163000_InitialCreate.Designer.cs +++ /dev/null @@ -1,220 +0,0 @@ -// -using System; -using Api.Context; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Api.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20240120163000_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.1") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Api.Entities.Article", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CtaTitle") - .IsRequired() - .HasColumnType("text"); - - b.Property("CtaUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("HeroImageAlt") - .IsRequired() - .HasColumnType("text"); - - b.Property("HeroImageUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Articles"); - }); - - modelBuilder.Entity("Api.Entities.Event", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("BadgeImage") - .IsRequired() - .HasColumnType("text"); - - b.Property("Deleted") - .HasColumnType("boolean"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("EndDateTime") - .HasColumnType("timestamp with time zone"); - - b.Property("Location") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("StartDateTime") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("Events"); - }); - - modelBuilder.Entity("Api.Entities.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Email") - .IsRequired() - .HasColumnType("text"); - - b.Property("FirebaseId") - .IsRequired() - .HasColumnType("text"); - - b.Property("GraduationYear") - .HasColumnType("integer"); - - b.Property("MemberId") - .IsRequired() - .HasColumnType("text"); - - b.Property("MemberType") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("FirebaseId") - .IsUnique(); - - b.HasIndex("MemberId") - .IsUnique(); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Api.Entities.UserEvent", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("EventId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("EventId"); - - b.HasIndex("UserId"); - - b.ToTable("UserEvents"); - }); - - modelBuilder.Entity("EventUser", b => - { - b.Property("AttendeesId") - .HasColumnType("uuid"); - - b.Property("EventsId") - .HasColumnType("uuid"); - - b.HasKey("AttendeesId", "EventsId"); - - b.HasIndex("EventsId"); - - b.ToTable("EventUser"); - }); - - modelBuilder.Entity("Api.Entities.UserEvent", b => - { - b.HasOne("Api.Entities.Event", "Event") - .WithMany("UserEvents") - .HasForeignKey("EventId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Api.Entities.User", "User") - .WithMany("UserEvents") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Event"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("EventUser", b => - { - b.HasOne("Api.Entities.User", null) - .WithMany() - .HasForeignKey("AttendeesId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Api.Entities.Event", null) - .WithMany() - .HasForeignKey("EventsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Api.Entities.Event", b => - { - b.Navigation("UserEvents"); - }); - - modelBuilder.Entity("Api.Entities.User", b => - { - b.Navigation("UserEvents"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Api/Migrations/20240120163000_InitialCreate.cs b/Api/Migrations/20240120163000_InitialCreate.cs deleted file mode 100644 index 15c3dc6..0000000 --- a/Api/Migrations/20240120163000_InitialCreate.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Api.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Articles", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Title = table.Column(type: "text", nullable: false), - Description = table.Column(type: "text", nullable: false), - HeroImageAlt = table.Column(type: "text", nullable: false), - HeroImageUrl = table.Column(type: "text", nullable: false), - CtaTitle = table.Column(type: "text", nullable: false), - CtaUrl = table.Column(type: "text", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Articles", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Events", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - Name = table.Column(type: "text", nullable: false), - Description = table.Column(type: "text", nullable: false), - Location = table.Column(type: "text", nullable: false), - BadgeImage = table.Column(type: "text", nullable: false), - StartDateTime = table.Column(type: "timestamp with time zone", nullable: false), - EndDateTime = table.Column(type: "timestamp with time zone", nullable: false), - Deleted = table.Column(type: "boolean", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Events", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Users", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - MemberId = table.Column(type: "text", nullable: false), - FirebaseId = table.Column(type: "text", nullable: false), - Name = table.Column(type: "text", nullable: false), - Email = table.Column(type: "text", nullable: false), - GraduationYear = table.Column(type: "integer", nullable: false), - MemberType = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "EventUser", - columns: table => new - { - AttendeesId = table.Column(type: "uuid", nullable: false), - EventsId = table.Column(type: "uuid", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_EventUser", x => new { x.AttendeesId, x.EventsId }); - table.ForeignKey( - name: "FK_EventUser_Events_EventsId", - column: x => x.EventsId, - principalTable: "Events", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_EventUser_Users_AttendeesId", - column: x => x.AttendeesId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "UserEvents", - columns: table => new - { - Id = table.Column(type: "uuid", nullable: false), - UserId = table.Column(type: "uuid", nullable: false), - EventId = table.Column(type: "uuid", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_UserEvents", x => x.Id); - table.ForeignKey( - name: "FK_UserEvents_Events_EventId", - column: x => x.EventId, - principalTable: "Events", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_UserEvents_Users_UserId", - column: x => x.UserId, - principalTable: "Users", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "IX_EventUser_EventsId", - table: "EventUser", - column: "EventsId"); - - migrationBuilder.CreateIndex( - name: "IX_UserEvents_EventId", - table: "UserEvents", - column: "EventId"); - - migrationBuilder.CreateIndex( - name: "IX_UserEvents_UserId", - table: "UserEvents", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_Users_FirebaseId", - table: "Users", - column: "FirebaseId", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_Users_MemberId", - table: "Users", - column: "MemberId", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Articles"); - - migrationBuilder.DropTable( - name: "EventUser"); - - migrationBuilder.DropTable( - name: "UserEvents"); - - migrationBuilder.DropTable( - name: "Events"); - - migrationBuilder.DropTable( - name: "Users"); - } - } -} diff --git a/Api/Migrations/AppDbContextModelSnapshot.cs b/Api/Migrations/AppDbContextModelSnapshot.cs deleted file mode 100644 index 8f4fcf1..0000000 --- a/Api/Migrations/AppDbContextModelSnapshot.cs +++ /dev/null @@ -1,217 +0,0 @@ -// -using System; -using Api.Context; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Api.Migrations -{ - [DbContext(typeof(AppDbContext))] - partial class AppDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.1") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Api.Entities.Article", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("CtaTitle") - .IsRequired() - .HasColumnType("text"); - - b.Property("CtaUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("HeroImageAlt") - .IsRequired() - .HasColumnType("text"); - - b.Property("HeroImageUrl") - .IsRequired() - .HasColumnType("text"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Articles"); - }); - - modelBuilder.Entity("Api.Entities.Event", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("BadgeImage") - .IsRequired() - .HasColumnType("text"); - - b.Property("Deleted") - .HasColumnType("boolean"); - - b.Property("Description") - .IsRequired() - .HasColumnType("text"); - - b.Property("EndDateTime") - .HasColumnType("timestamp with time zone"); - - b.Property("Location") - .IsRequired() - .HasColumnType("text"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("StartDateTime") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.ToTable("Events"); - }); - - modelBuilder.Entity("Api.Entities.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("Email") - .IsRequired() - .HasColumnType("text"); - - b.Property("FirebaseId") - .IsRequired() - .HasColumnType("text"); - - b.Property("GraduationYear") - .HasColumnType("integer"); - - b.Property("MemberId") - .IsRequired() - .HasColumnType("text"); - - b.Property("MemberType") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("FirebaseId") - .IsUnique(); - - b.HasIndex("MemberId") - .IsUnique(); - - b.ToTable("Users"); - }); - - modelBuilder.Entity("Api.Entities.UserEvent", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uuid"); - - b.Property("EventId") - .HasColumnType("uuid"); - - b.Property("UserId") - .HasColumnType("uuid"); - - b.HasKey("Id"); - - b.HasIndex("EventId"); - - b.HasIndex("UserId"); - - b.ToTable("UserEvents"); - }); - - modelBuilder.Entity("EventUser", b => - { - b.Property("AttendeesId") - .HasColumnType("uuid"); - - b.Property("EventsId") - .HasColumnType("uuid"); - - b.HasKey("AttendeesId", "EventsId"); - - b.HasIndex("EventsId"); - - b.ToTable("EventUser"); - }); - - modelBuilder.Entity("Api.Entities.UserEvent", b => - { - b.HasOne("Api.Entities.Event", "Event") - .WithMany("UserEvents") - .HasForeignKey("EventId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Api.Entities.User", "User") - .WithMany("UserEvents") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Event"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("EventUser", b => - { - b.HasOne("Api.Entities.User", null) - .WithMany() - .HasForeignKey("AttendeesId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Api.Entities.Event", null) - .WithMany() - .HasForeignKey("EventsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Api.Entities.Event", b => - { - b.Navigation("UserEvents"); - }); - - modelBuilder.Entity("Api.Entities.User", b => - { - b.Navigation("UserEvents"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Api/Protos/v1/user.proto b/Api/Protos/v1/user.proto deleted file mode 100644 index 30cf664..0000000 --- a/Api/Protos/v1/user.proto +++ /dev/null @@ -1,107 +0,0 @@ -syntax = "proto3"; - -import "google/protobuf/field_mask.proto"; -import "google/protobuf/empty.proto"; -import "google/api/annotations.proto"; - -package user.v1; - -// The type of a member -enum UserMemberType { - Exco = 0; - Associate = 1; - Affiliate = 2; - Ordinary = 3; - Revoked = 4; -} - -message User { - string id = 1; - string memberId = 2; - UserMemberType memberType = 3; - string name = 4; - string email = 5; - int32 graduationYear = 6; -} - -// User service -service UserService { - // List all users, restricted to EXCO - rpc ListUsers (ListUsersRequest) returns (ListUsersResponse) { - option (google.api.http) = { - get: "/v1/users" - }; - }; - - // Get user information, restricted to authenticated users - rpc GetUser (GetUserRequest) returns (User) { - option (google.api.http) = { - get: "/v1/users/{id}" - }; - }; - - // Create user - rpc CreateUser (CreateUserRequest) returns (User) { - option (google.api.http) = { - post: "/v1/users" - body: "user" - }; - }; - - // Batch create users - rpc BatchCreateUsers (BatchCreateUsersRequest) returns (BatchCreateUsersResponse) { - option (google.api.http) = { - post: "/v1/users:batchCreate" - body: "*" - }; - } - - // Update user - rpc UpdateUser (UpdateUserRequest) returns (User) { - option (google.api.http) = { - patch: "/v1/users/{user.id}" - body: "user" - }; - } - - // Delete user - rpc DeleteUser (DeleteUserRequest) returns (google.protobuf.Empty) { - option (google.api.http) = { - delete: "/v1/users/{id}" - }; - } -} - -message ListUsersRequest { - int32 pageSize = 1; - string pageToken = 2; -} - -message ListUsersResponse { - repeated User users = 1; -} - -message GetUserRequest { - string id = 1; -} - -message CreateUserRequest { - User user = 1; -} - -message BatchCreateUsersRequest { - repeated CreateUserRequest requests = 1; -} - -message BatchCreateUsersResponse { - repeated User users = 1; -} - -message UpdateUserRequest { - User user = 1; - google.protobuf.FieldMask updateMask = 2; -} - -message DeleteUserRequest { - string id = 1; -} diff --git a/Api/Services/V1/ArticleService.cs b/Api/Services/V1/ArticleService.cs deleted file mode 100644 index 4ba0b67..0000000 --- a/Api/Services/V1/ArticleService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Article.V1; -using Auth.V1; -using Grpc.Core; - -namespace Api.Services.V1; - -public class ArticleServiceV1: ArticleService.ArticleServiceBase -{ -} diff --git a/Api/Services/V1/AuthService.cs b/Api/Services/V1/AuthService.cs deleted file mode 100644 index 3120570..0000000 --- a/Api/Services/V1/AuthService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Auth.V1; -using Grpc.Core; - -namespace Api.Services.V1; - -public class AuthServiceV1: AuthService.AuthServiceBase -{ - public override Task VerifyUser(VerifyUserRequest request, ServerCallContext context) - { - return base.VerifyUser(request, context); - } -} diff --git a/Api/Services/V1/UserService.cs b/Api/Services/V1/UserService.cs deleted file mode 100644 index 94609a8..0000000 --- a/Api/Services/V1/UserService.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Api.Authorization; -using Api.Context; -using Api.Extensions; -using Google.Protobuf.WellKnownTypes; -using Grpc.Core; -using Microsoft.AspNetCore.Authorization; -using Microsoft.EntityFrameworkCore; -using User.V1; -using Enum = System.Enum; -using UserMemberType = Api.Entities.UserMemberType; - -namespace Api.Services.V1; - -/// -public class UserServiceV1(ILogger logger, AppDbContext dbContext) : UserService.UserServiceBase -{ - [AuthorizeMembers(UserMemberType.Exco)] - public override async Task ListUsers(ListUsersRequest request, ServerCallContext context) - { - return new ListUsersResponse - { - Users = - { - dbContext.Users.Select(u => u.ToGrpcUser()) - }, - }; - } - - public override Task GetUser(GetUserRequest request, ServerCallContext context) - { - return base.GetUser(request, context); - } - - public override Task CreateUser(CreateUserRequest request, ServerCallContext context) - { - return base.CreateUser(request, context); - } - - public override Task BatchCreateUsers(BatchCreateUsersRequest request, ServerCallContext context) - { - return base.BatchCreateUsers(request, context); - } - - public override Task UpdateUser(UpdateUserRequest request, ServerCallContext context) - { - return base.UpdateUser(request, context); - } - - public override Task DeleteUser(DeleteUserRequest request, ServerCallContext context) - { - return base.DeleteUser(request, context); - } -} \ No newline at end of file diff --git a/Api/Api.http b/SSTAlumniAssociation.Api/Api.http similarity index 100% rename from Api/Api.http rename to SSTAlumniAssociation.Api/Api.http diff --git a/SSTAlumniAssociation.Api/Authorization/Admin/AdminExcoHandler.cs b/SSTAlumniAssociation.Api/Authorization/Admin/AdminExcoHandler.cs new file mode 100644 index 0000000..041ff03 --- /dev/null +++ b/SSTAlumniAssociation.Api/Authorization/Admin/AdminExcoHandler.cs @@ -0,0 +1,20 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; + +namespace SSTAlumniAssociation.Api.Authorization.Admin; + +/// +public class AdminExcoHandler : AuthorizationHandler +{ + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AdminRequirement requirement) + { + var role = context.User.Claims.SingleOrDefault(c => c.Type == ClaimTypes.Role); + + if (role is not null && role.Value == Entities.Membership.Exco.ToString()) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Authorization/Admin/AdminRequirement.cs b/SSTAlumniAssociation.Api/Authorization/Admin/AdminRequirement.cs new file mode 100644 index 0000000..ef2ec37 --- /dev/null +++ b/SSTAlumniAssociation.Api/Authorization/Admin/AdminRequirement.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Authorization; +using SSTAlumniAssociation.Api.Entities; + +namespace SSTAlumniAssociation.Api.Authorization.Admin; + +/// +/// Users must be either a or with an member type. +/// Users which fulfill this requirement will be able to access admin capabilities, which includes reading PII and modifying data. +/// +/// Please grant with caution. +/// +public class AdminRequirement : IAuthorizationRequirement; \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Authorization/Admin/AdminSystemAdminHandler.cs b/SSTAlumniAssociation.Api/Authorization/Admin/AdminSystemAdminHandler.cs new file mode 100644 index 0000000..151ac81 --- /dev/null +++ b/SSTAlumniAssociation.Api/Authorization/Admin/AdminSystemAdminHandler.cs @@ -0,0 +1,21 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using SSTAlumniAssociation.Api.Entities; + +namespace SSTAlumniAssociation.Api.Authorization.Admin; + +/// +public class AdminSystemAdminHandler : AuthorizationHandler +{ + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AdminRequirement requirement) + { + var role = context.User.Claims.SingleOrDefault(c => c.Type == ClaimTypes.Role); + + if (role is not null && role.Value == nameof(SystemAdmin)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Authorization/AuthorizeAdminAttribute.cs b/SSTAlumniAssociation.Api/Authorization/AuthorizeAdminAttribute.cs new file mode 100644 index 0000000..525e910 --- /dev/null +++ b/SSTAlumniAssociation.Api/Authorization/AuthorizeAdminAttribute.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Authorization; + +namespace SSTAlumniAssociation.Api.Authorization; + +/// +/// Syntax sugar for [Authorize(Policy = Policies.Admin)] +/// +public class AuthorizeAdminAttribute : AuthorizeAttribute +{ + public AuthorizeAdminAttribute() + { + Policy = Policies.Admin; + } +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Authorization/AuthorizeMemberAttribute.cs b/SSTAlumniAssociation.Api/Authorization/AuthorizeMemberAttribute.cs new file mode 100644 index 0000000..8a73720 --- /dev/null +++ b/SSTAlumniAssociation.Api/Authorization/AuthorizeMemberAttribute.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Authorization; + +namespace SSTAlumniAssociation.Api.Authorization; + +/// +/// Syntax sugar for [Authorize(Policy = Policies.Member)] +/// +public class AuthorizeMemberAttribute : AuthorizeAttribute +{ + public AuthorizeMemberAttribute() + { + Policy = Policies.Member; + } +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Authorization/ClaimsTransformation.cs b/SSTAlumniAssociation.Api/Authorization/ClaimsTransformation.cs new file mode 100644 index 0000000..40ac7c4 --- /dev/null +++ b/SSTAlumniAssociation.Api/Authorization/ClaimsTransformation.cs @@ -0,0 +1,62 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using SSTAlumniAssociation.Api.Context; +using SSTAlumniAssociation.Api.Entities; + +namespace SSTAlumniAssociation.Api.Authorization; + +public class ClaimsTransformation(AppDbContext dbContext) : IClaimsTransformation +{ + public async Task TransformAsync(ClaimsPrincipal principal) + { + var sub = principal.Claims.SingleOrDefault(c => c.Type == ClaimTypes.NameIdentifier); + if (sub is null) + { + return principal; + } + + var user = await dbContext.Users.SingleOrDefaultAsync(u => u.FirebaseId == sub.Value); + if (user is null) + { + return principal; + } + + if (principal.Identity is not ClaimsIdentity identity) + { + return principal; + } + + var existingRoleClaim = identity.FindFirst(ClaimTypes.Role); + if (existingRoleClaim is not null) + { + identity.RemoveClaim(existingRoleClaim); + } + + switch (user) + { + case Entities.Member m: + identity.AddClaim(new Claim(ClaimTypes.Role, m.Membership.ToString())); + break; + case ServiceAccount: + identity.AddClaim(new Claim(ClaimTypes.Role, nameof(ServiceAccount))); + break; + case Employee: + identity.AddClaim(new Claim(ClaimTypes.Role, nameof(Employee))); + break; + case SystemAdmin: + identity.AddClaim(new Claim(ClaimTypes.Role, nameof(SystemAdmin))); + break; + } + + var existingNameIdentifierClaim = identity.FindFirst(ClaimTypes.NameIdentifier); + if (existingNameIdentifierClaim is not null) + { + identity.RemoveClaim(existingNameIdentifierClaim); + } + + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())); + + return principal; + } +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Authorization/Member/MemberNonRevokedHandler.cs b/SSTAlumniAssociation.Api/Authorization/Member/MemberNonRevokedHandler.cs new file mode 100644 index 0000000..63e4493 --- /dev/null +++ b/SSTAlumniAssociation.Api/Authorization/Member/MemberNonRevokedHandler.cs @@ -0,0 +1,20 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; + +namespace SSTAlumniAssociation.Api.Authorization.Member; + +/// +public class MemberNonRevokedHandler : AuthorizationHandler +{ + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MemberRequirement requirement) + { + var role = context.User.Claims.SingleOrDefault(c => c.Type == ClaimTypes.Role); + + if (role is not null && role.Value != Entities.Membership.Revoked.ToString()) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Authorization/Member/MemberRequirement.cs b/SSTAlumniAssociation.Api/Authorization/Member/MemberRequirement.cs new file mode 100644 index 0000000..94b7e5e --- /dev/null +++ b/SSTAlumniAssociation.Api/Authorization/Member/MemberRequirement.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Authorization; +using SSTAlumniAssociation.Api.Entities; + +namespace SSTAlumniAssociation.Api.Authorization.Member; + +/// +/// User must be a with a member type that is not . +/// +public class MemberRequirement : IAuthorizationRequirement; \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Authorization/OwnerOrAdmin/OwnerOrAdminHandler.cs b/SSTAlumniAssociation.Api/Authorization/OwnerOrAdmin/OwnerOrAdminHandler.cs new file mode 100644 index 0000000..58e36ab --- /dev/null +++ b/SSTAlumniAssociation.Api/Authorization/OwnerOrAdmin/OwnerOrAdminHandler.cs @@ -0,0 +1,34 @@ +using SSTAlumniAssociation.Api.Extensions; +using Microsoft.AspNetCore.Authorization; + +namespace SSTAlumniAssociation.Api.Authorization.OwnerOrAdmin; + +/// +public class OwnerOrAdminHandler(IServiceProvider serviceProvider) + : AuthorizationHandler +{ + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + OwnerOrAdminRequirement requirement, + string? userId) + { + // Manually get service to prevent circular dependencies + await using var scope = serviceProvider.CreateAsyncScope(); + var authorizationService = scope.ServiceProvider.GetRequiredService(); + + // If a user is admin, grant permission to access user data + var userIsAdmin = await authorizationService.AuthorizeAsync(context.User, Policies.Admin); + if (userIsAdmin.Succeeded) + { + context.Succeed(requirement); + return; + } + + // If a user is reading their own data, grant permission + if (requirement.Name == OwnerOrAdminOperations.Read.Name && + userId == context.User.Claims.GetNameIdentifier()) + { + context.Succeed(requirement); + } + } +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Authorization/OwnerOrAdmin/OwnerOrAdminRequirement.cs b/SSTAlumniAssociation.Api/Authorization/OwnerOrAdmin/OwnerOrAdminRequirement.cs new file mode 100644 index 0000000..b96bc34 --- /dev/null +++ b/SSTAlumniAssociation.Api/Authorization/OwnerOrAdmin/OwnerOrAdminRequirement.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Authorization; + +namespace SSTAlumniAssociation.Api.Authorization.OwnerOrAdmin; + +/// +/// For the operations below: +/// +/// +/// +/// : the user must pass the . +/// +/// +/// +/// : the user is able to view their own data, but must pass the +/// in order to view data of other users. +/// +/// +/// +/// : the user must pass the . This is +/// because SSTAA does not allow self-service user profile updates at the moment, although it might be allowed in the future. +/// +/// +/// +/// : the user must pass the . +/// +/// +/// +/// +public class OwnerOrAdminRequirement : IAuthorizationRequirement +{ + public string Name { get; set; } +}; + +public class OwnerOrAdminOperations +{ + public static OwnerOrAdminRequirement Create = new() { Name = nameof(Create) }; + public static OwnerOrAdminRequirement Read = new() { Name = nameof(Read) }; + public static OwnerOrAdminRequirement Update = new() { Name = nameof(Update) }; + public static OwnerOrAdminRequirement Delete = new() { Name = nameof(Delete) }; +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Authorization/Policies.cs b/SSTAlumniAssociation.Api/Authorization/Policies.cs new file mode 100644 index 0000000..1c7fab3 --- /dev/null +++ b/SSTAlumniAssociation.Api/Authorization/Policies.cs @@ -0,0 +1,8 @@ +namespace SSTAlumniAssociation.Api.Authorization; + +public class Policies +{ + public const string Admin = "Admin"; + public const string Member = "Member"; + public const string OwnerOrAdmin = "OwnerOrAdmin"; +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Context/AppDbContext.cs b/SSTAlumniAssociation.Api/Context/AppDbContext.cs new file mode 100644 index 0000000..8f61596 --- /dev/null +++ b/SSTAlumniAssociation.Api/Context/AppDbContext.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using SSTAlumniAssociation.Api.Entities; + +namespace SSTAlumniAssociation.Api.Context; + +public partial class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) + : base(options) + { + } + + // Users + public DbSet Users { get; set; } + public DbSet Members { get; set; } + public DbSet AlumniMembers { get; set; } + public DbSet EmployeeMembers { get; set; } + public DbSet Employees { get; set; } + public DbSet ServiceAccounts { get; set; } + public DbSet SystemAdmins { get; set; } + + public DbSet Events { get; set; } + public DbSet UserEvents { get; set; } + + public DbSet Articles { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Store enum value as string to reduce risk of breaking changes vs storing as an int. + modelBuilder + .Entity() + .Property(u => u.Membership) + .HasConversion(); + + modelBuilder + .Entity() + .Property(a => a.GraduationYear) + .HasColumnName("GraduationYear"); + + modelBuilder + .Entity() + .Property(e => e.GraduationYear) + .HasColumnName("GraduationYear"); + + modelBuilder.Entity() + .HasData( + new SystemAdmin( + id: Guid.Parse("df90f5ea-a236-413f-a6c1-ca9197427631"), + name: "Qin Guan", + firebaseId: "GuZZVeOdlhNsf5dZGQmU2yV1Ox33", + email: "qinguan20040914@gmail.com" + ) + ); + + modelBuilder.Entity() + .HasData( + new AlumniMember( + id: Guid.Parse("829bc4dc-2d8f-46df-acbb-c52c0e7f958f"), + name: "Tan Zheng Jie", + firebaseId: "5ZPERFPTvfMfxwhH7SGsOmXqSco2", + email: "tan_zheng_jie@sstaa.org", + membership: Membership.Exco, + memberId: "EXCO-1" + ) + ); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} \ No newline at end of file diff --git a/Api/Dockerfile b/SSTAlumniAssociation.Api/Dockerfile similarity index 100% rename from Api/Dockerfile rename to SSTAlumniAssociation.Api/Dockerfile diff --git a/Api/Entities/Article.cs b/SSTAlumniAssociation.Api/Entities/Article.cs similarity index 86% rename from Api/Entities/Article.cs rename to SSTAlumniAssociation.Api/Entities/Article.cs index f2c29a9..a64d991 100644 --- a/Api/Entities/Article.cs +++ b/SSTAlumniAssociation.Api/Entities/Article.cs @@ -1,4 +1,4 @@ -namespace Api.Entities; +namespace SSTAlumniAssociation.Api.Entities; public class Article { @@ -9,4 +9,4 @@ public class Article public string HeroImageUrl { get; set; } public string CtaTitle { get; set; } public string CtaUrl { get; set; } -} \ No newline at end of file +} diff --git a/SSTAlumniAssociation.Api/Entities/Employee.cs b/SSTAlumniAssociation.Api/Entities/Employee.cs new file mode 100644 index 0000000..1a8ea65 --- /dev/null +++ b/SSTAlumniAssociation.Api/Entities/Employee.cs @@ -0,0 +1,12 @@ +namespace SSTAlumniAssociation.Api.Entities; + +public class Employee : User +{ + public Employee(User user) : base(user) + { + } + + public Employee(Guid id, string name, string email, string firebaseId) : base(id, name, email, firebaseId) + { + } +}; \ No newline at end of file diff --git a/Api/Entities/Event.cs b/SSTAlumniAssociation.Api/Entities/Event.cs similarity index 72% rename from Api/Entities/Event.cs rename to SSTAlumniAssociation.Api/Entities/Event.cs index 092d169..5553526 100644 --- a/Api/Entities/Event.cs +++ b/SSTAlumniAssociation.Api/Entities/Event.cs @@ -1,21 +1,16 @@ -namespace Api.Entities; +namespace SSTAlumniAssociation.Api.Entities; public class Event { public Guid Id { get; set; } - + public string Name { get; set; } public string Description { get; set; } public string Location { get; set; } public string BadgeImage { get; set; } public DateTime StartDateTime { get; set; } public DateTime EndDateTime { get; set; } - - /// - /// Indicates if the event is deleted. - /// - public bool Deleted { get; set; } - + public List Attendees { get; set; } public List UserEvents { get; set; } } \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Entities/Group.cs b/SSTAlumniAssociation.Api/Entities/Group.cs new file mode 100644 index 0000000..d896749 --- /dev/null +++ b/SSTAlumniAssociation.Api/Entities/Group.cs @@ -0,0 +1,12 @@ +namespace SSTAlumniAssociation.Api.Entities; + +/// +/// A group represents any collection of users, for example, a Special Interest Group (SIG) +/// +public class Group +{ + public Guid Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public List Members { get; set; } +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Entities/Member.cs b/SSTAlumniAssociation.Api/Entities/Member.cs new file mode 100644 index 0000000..3ffb968 --- /dev/null +++ b/SSTAlumniAssociation.Api/Entities/Member.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore; + +namespace SSTAlumniAssociation.Api.Entities; + +/// +/// The role that the member has within SSTAA. +/// This value is stored in the database as a string. +/// This enum should rarely ever be modified, as it will very likely cause a breaking change. +/// +public enum Membership +{ + Exco, + + /// + /// All past/present staff and students who completed at least 1 year of study in SST but did not graduate + /// + Associate, + + /// + /// All graduated alumni who are under 21 + /// + Affiliate, + Ordinary, + Revoked, +} + +[Index(nameof(MemberId), IsUnique = true)] +public abstract class Member : User +{ + public Member(User user) : base(user) + { + } + + public Member(Guid id, string name, string email, string firebaseId, Membership membership, string memberId) : + base(id, name, email, firebaseId) + { + Membership = membership; + MemberId = memberId; + } + + public Membership Membership { get; set; } + + public List Groups { get; set; } + + /// + /// Internal member ID used for tracking by SSTAA admin. + /// + public string MemberId { get; set; } +} + +/// +/// If the member was previously a student of SST, they are considered an alumni member. +/// +public class AlumniMember : Member +{ + public AlumniMember(User user) : base(user) + { + } + + public AlumniMember(Guid id, string name, string email, string firebaseId, Membership membership, string memberId) : + base(id, name, email, firebaseId, membership, memberId) + { + } + + /// + /// In the case of an associate member, they may have studied in SST but not graduated. + /// In this case, the membership granted should be limited to + /// + public int? GraduationYear { get; set; } +} + +/// +/// If the member is a current staff, or ex-staff of SST, they are considered a staff member. +/// This differs from as any user with StaffMember can be assumed to be a registered SSTAA member. +/// can also only be used for current staff. +/// +/// This member can only have membership of . +/// +public class EmployeeMember : Member +{ + public EmployeeMember(User user) : base(user) + { + } + + public EmployeeMember(Guid id, string name, string email, string firebaseId, Membership membership, string memberId) + : base(id, name, email, firebaseId, membership, memberId) + { + } + + public int? GraduationYear { get; set; } +}; \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Entities/ServiceAccount.cs b/SSTAlumniAssociation.Api/Entities/ServiceAccount.cs new file mode 100644 index 0000000..a288c1c --- /dev/null +++ b/SSTAlumniAssociation.Api/Entities/ServiceAccount.cs @@ -0,0 +1,22 @@ +namespace SSTAlumniAssociation.Api.Entities; + +public enum ServiceAccountType +{ + GuardHouse, +} + +public class ServiceAccount : User +{ + public ServiceAccount(Guid id, string name, string email, string firebaseId, ServiceAccountType serviceAccountType) + : base(id, name, email, firebaseId) + { + ServiceAccountType = serviceAccountType; + } + + public ServiceAccount(User user, ServiceAccountType serviceAccountType) : base(user) + { + ServiceAccountType = serviceAccountType; + } + + public ServiceAccountType ServiceAccountType { get; set; } +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Entities/SystemAdmin.cs b/SSTAlumniAssociation.Api/Entities/SystemAdmin.cs new file mode 100644 index 0000000..a62afe8 --- /dev/null +++ b/SSTAlumniAssociation.Api/Entities/SystemAdmin.cs @@ -0,0 +1,12 @@ +namespace SSTAlumniAssociation.Api.Entities; + +public class SystemAdmin : User +{ + public SystemAdmin(Guid id, string name, string email, string firebaseId) : base(id, name, email, firebaseId) + { + } + + public SystemAdmin(User user) : base(user) + { + } +}; \ No newline at end of file diff --git a/Api/Entities/User.cs b/SSTAlumniAssociation.Api/Entities/User.cs similarity index 55% rename from Api/Entities/User.cs rename to SSTAlumniAssociation.Api/Entities/User.cs index c3386dd..e9eacc9 100644 --- a/Api/Entities/User.cs +++ b/SSTAlumniAssociation.Api/Entities/User.cs @@ -1,32 +1,42 @@ +using FluentValidation; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -namespace Api.Entities; +namespace SSTAlumniAssociation.Api.Entities; - -[Index(nameof(MemberId), IsUnique = true)] [Index(nameof(FirebaseId), IsUnique = true)] public class User { + public User(Guid id, string name, string email, string firebaseId) + { + Id = id; + Name = name; + Email = email; + FirebaseId = firebaseId; + } + + public User(User user) + { + Id = user.Id; + Name = user.Name; + Email = user.Email; + FirebaseId = user.FirebaseId; + } + public Guid Id { get; set; } - + /// - /// Member ID for tracking by SSTAA Admin. + /// Ignore Firebase Auth provided values and force user to provide their own. /// - public string MemberId { get; set; } - + public string Name { get; set; } + + public string Email { get; set; } + /// /// Use Firebase Auth provided ID as SSOT. /// public string FirebaseId { get; set; } - - /// - /// Ignore Firebase Auth provided values and force user to provide their own. - /// - public string Name { get; set; } - public string Email { get; set; } - public int GraduationYear { get; set; } - public UserMemberType MemberType { get; set; } - + public List Events { get; set; } public List UserEvents { get; set; } } diff --git a/Api/Entities/UserEvent.cs b/SSTAlumniAssociation.Api/Entities/UserEvent.cs similarity index 64% rename from Api/Entities/UserEvent.cs rename to SSTAlumniAssociation.Api/Entities/UserEvent.cs index f962a76..f927ca0 100644 --- a/Api/Entities/UserEvent.cs +++ b/SSTAlumniAssociation.Api/Entities/UserEvent.cs @@ -1,19 +1,14 @@ -namespace Api.Entities; +namespace SSTAlumniAssociation.Api.Entities; public class UserEvent { public Guid Id { get; set; } - + /// /// The admission key is the same as the ID generated /// public Guid AdmissionKey => Id; - /// - /// UserEvent is deleted if parent is deleted - /// - public bool Deleted => Event.Deleted; - public Guid UserId { get; set; } public User User { get; set; } public Guid EventId { get; set; } diff --git a/SSTAlumniAssociation.Api/Extensions/Claims.cs b/SSTAlumniAssociation.Api/Extensions/Claims.cs new file mode 100644 index 0000000..4bce2a6 --- /dev/null +++ b/SSTAlumniAssociation.Api/Extensions/Claims.cs @@ -0,0 +1,26 @@ +using System.Security.Claims; + +namespace SSTAlumniAssociation.Api.Extensions; + +public static class Claims +{ + /// + /// Retrieves the name identifier (represents as modified by ) + /// + /// + /// ID of user + public static string GetNameIdentifier(this IEnumerable claims) + { + return claims.Single(c => c.Type == ClaimTypes.NameIdentifier).Value; + } + + /// + /// Retrieves the name identifier (represents as modified by ) + /// + /// + /// ID of user + public static Guid GetNameIdentifierGuid(this IEnumerable claims) + { + return Guid.Parse(claims.Single(c => c.Type == ClaimTypes.NameIdentifier).Value); + } +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Extensions/User.cs b/SSTAlumniAssociation.Api/Extensions/User.cs new file mode 100644 index 0000000..86ee12e --- /dev/null +++ b/SSTAlumniAssociation.Api/Extensions/User.cs @@ -0,0 +1,147 @@ +using SSTAlumniAssociation.Api.Entities; + +namespace SSTAlumniAssociation.Api.Extensions; + +public static class UserExtensions +{ + public static ServiceAccountType ToServiceAccountType(this User.V1.ServiceAccountType serviceAccountType) + { + return Enum.Parse(serviceAccountType.ToString()); + } + + public static Membership ToMembership(this User.V1.Membership membership) + { + return Enum.Parse(membership.ToString()); + } + + public static User.V1.Membership ToGrpcMembership(this Membership membership) + { + return Enum.Parse(membership.ToString()); + } + + public static User.V1.ServiceAccountType ToGrpcServiceAccountType(this ServiceAccountType serviceAccountType) + { + return Enum.Parse(serviceAccountType.ToString()); + } + + public static Entities.User ToUser(this User.V1.User user) + { + var u = new Entities.User( + id: Guid.Parse(user.Id), + name: user.Name, + email: user.Email, + firebaseId: user.FirebaseId + ); + + switch (user.UserTypeCase) + { + case User.V1.User.UserTypeOneofCase.None: + return u; + case User.V1.User.UserTypeOneofCase.ServiceAccount: + return new ServiceAccount( + u, + serviceAccountType: user.ServiceAccount.ServiceAccountType.ToServiceAccountType() + ); + case User.V1.User.UserTypeOneofCase.SystemAdmin: + return new SystemAdmin(u); + case User.V1.User.UserTypeOneofCase.Employee: + return new Employee(u); + case User.V1.User.UserTypeOneofCase.Member: + switch (user.Member.MemberTypeCase) + { + case User.V1.Member.MemberTypeOneofCase.AlumniMember: + return new AlumniMember(u) + { + GraduationYear = user.Member.AlumniMember.GraduationYear + }; + case User.V1.Member.MemberTypeOneofCase.EmployeeMember: + return new EmployeeMember(u) + { + GraduationYear = user.Member.EmployeeMember.GraduationYear + }; + case User.V1.Member.MemberTypeOneofCase.None: + default: + throw new Exception("User is not a valid member."); + } + default: + throw new Exception("Unrecognized user type."); + } + } + + public static User.V1.User ToGrpcUser(this Entities.User user) + { + switch (user) + { + case Employee e: + return new User.V1.User + { + Id = e.Id.ToString(), + Name = e.Name, + Email = e.Email, + FirebaseId = e.FirebaseId, + }; + case SystemAdmin systemAdmin: + return new User.V1.User + { + Id = systemAdmin.Id.ToString(), + Name = systemAdmin.Name, + Email = systemAdmin.Email, + FirebaseId = systemAdmin.FirebaseId, + SystemAdmin = new User.V1.SystemAdmin() + }; + case ServiceAccount serviceAccount: + return new User.V1.User + { + Id = serviceAccount.Id.ToString(), + Name = serviceAccount.Name, + Email = serviceAccount.Email, + FirebaseId = serviceAccount.FirebaseId, + ServiceAccount = new User.V1.ServiceAccount + { + ServiceAccountType = serviceAccount.ServiceAccountType.ToGrpcServiceAccountType() + } + }; + case Member m: + var member = new User.V1.Member + { + MemberId = m.MemberId, + Membership = m.Membership.ToGrpcMembership(), + }; + + switch (m) + { + case AlumniMember am: + member.AlumniMember = new User.V1.AlumniMember(); + if (am.GraduationYear.HasValue) + { + member.AlumniMember.GraduationYear = am.GraduationYear.GetValueOrDefault(); + } + + break; + + case EmployeeMember sm: + member.EmployeeMember = new User.V1.EmployeeMember(); + if (sm.GraduationYear.HasValue) + { + member.AlumniMember.GraduationYear = sm.GraduationYear.GetValueOrDefault(); + } + + break; + + default: + throw new Exception("Unknown member type detected."); + } + + return new User.V1.User + { + Id = m.Id.ToString(), + Name = m.Name, + Email = m.Email, + FirebaseId = m.FirebaseId, + Member = member, + }; + default: + throw new Exception("Unknown user type detected."); + } + } +} \ No newline at end of file diff --git a/Api/Program.cs b/SSTAlumniAssociation.Api/Program.cs similarity index 63% rename from Api/Program.cs rename to SSTAlumniAssociation.Api/Program.cs index 82ca29c..3981ae7 100644 --- a/Api/Program.cs +++ b/SSTAlumniAssociation.Api/Program.cs @@ -1,8 +1,14 @@ -using Api.Authorization; -using Api.Context; -using Api.Services.V1; +using SSTAlumniAssociation.Api.Authorization; +using SSTAlumniAssociation.Api.Authorization.Admin; +using SSTAlumniAssociation.Api.Authorization.Member; +using SSTAlumniAssociation.Api.Authorization.OwnerOrAdmin; +using SSTAlumniAssociation.Api.Context; +using SSTAlumniAssociation.Api.Services.V1; +using Calzolari.Grpc.AspNetCore.Validation; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; @@ -18,6 +24,14 @@ .AddAspNetCoreInstrumentation() .AddConsoleExporter()); + +builder.Services.AddTransient(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { @@ -32,16 +46,32 @@ }; }); -builder.Services.AddAuthorization(); +builder.Services.AddAuthorizationBuilder() + .AddPolicy(Policies.Admin, policy => + policy.AddRequirements(new AdminRequirement())) + .AddPolicy(Policies.Member, policy => + policy.AddRequirements(new MemberRequirement())) + .AddPolicy(Policies.OwnerOrAdmin, policy => + policy.AddRequirements(new OwnerOrAdminRequirement())); builder.Services - .AddGrpc() + .AddGrpc(options => options.EnableMessageValidation()) .AddJsonTranscoding(); +builder.Services.AddValidator(); +builder.Services.AddGrpcValidation(); + builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "SST Alumni Association API", Version = "v1" }); + if (builder.Environment.IsDevelopment()) + { + c.AddServer(new OpenApiServer { Description = "Development", Url = "http://localhost:5200" }); + } + + c.AddServer(new OpenApiServer { Description = "Production", Url = "https://api.sstaa.org" }); + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { In = ParameterLocation.Header, @@ -69,7 +99,7 @@ } ); - var filePath = Path.Combine(AppContext.BaseDirectory, "Api.xml"); + var filePath = Path.Combine(AppContext.BaseDirectory, "SSTAlumniAssociation.Api.xml"); c.IncludeXmlComments(filePath); c.IncludeGrpcXmlComments(filePath, includeControllerXmlComments: true); }); @@ -80,8 +110,6 @@ builder.Configuration.GetConnectionString("Postgres") ); -builder.Services.AddTransient(); - var app = builder.Build(); using (var scope = app.Services.CreateScope()) @@ -93,6 +121,7 @@ app.UseAuthentication(); app.UseAuthorization(); +// Require authorization by default and opt-out for anonymous routes app.MapGrpcService(); app.MapGrpcService(); app.MapGrpcService(); diff --git a/Api/Properties/launchSettings.json b/SSTAlumniAssociation.Api/Properties/launchSettings.json similarity index 100% rename from Api/Properties/launchSettings.json rename to SSTAlumniAssociation.Api/Properties/launchSettings.json diff --git a/SSTAlumniAssociation.Api/Protos/google/api/annotations.proto b/SSTAlumniAssociation.Api/Protos/google/api/annotations.proto new file mode 100644 index 0000000..5a85194 --- /dev/null +++ b/SSTAlumniAssociation.Api/Protos/google/api/annotations.proto @@ -0,0 +1,31 @@ +// Copyright 2015 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package google.api; + +import "Protos/google/api/http.proto"; +import "google/protobuf/descriptor.proto"; + +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "AnnotationsProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +extend google.protobuf.MethodOptions { + // See `HttpRule`. + HttpRule http = 72295728; +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Protos/google/api/http.proto b/SSTAlumniAssociation.Api/Protos/google/api/http.proto new file mode 100644 index 0000000..c839238 --- /dev/null +++ b/SSTAlumniAssociation.Api/Protos/google/api/http.proto @@ -0,0 +1,379 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package google.api; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "HttpProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +// Defines the HTTP configuration for an API service. It contains a list of +// [HttpRule][google.api.HttpRule], each specifying the mapping of an RPC method +// to one or more HTTP REST API methods. +message Http { + // A list of HTTP configuration rules that apply to individual API methods. + // + // **NOTE:** All service configuration rules follow "last one wins" order. + repeated HttpRule rules = 1; + + // When set to true, URL path parameters will be fully URI-decoded except in + // cases of single segment matches in reserved expansion, where "%2F" will be + // left encoded. + // + // The default behavior is to not decode RFC 6570 reserved characters in multi + // segment matches. + bool fully_decode_reserved_expansion = 2; +} + +// # gRPC Transcoding +// +// gRPC Transcoding is a feature for mapping between a gRPC method and one or +// more HTTP REST endpoints. It allows developers to build a single API service +// that supports both gRPC APIs and REST APIs. Many systems, including [Google +// APIs](https://github.com/googleapis/googleapis), +// [Cloud Endpoints](https://cloud.google.com/endpoints), [gRPC +// Gateway](https://github.com/grpc-ecosystem/grpc-gateway), +// and [Envoy](https://github.com/envoyproxy/envoy) proxy support this feature +// and use it for large scale production services. +// +// `HttpRule` defines the schema of the gRPC/REST mapping. The mapping specifies +// how different portions of the gRPC request message are mapped to the URL +// path, URL query parameters, and HTTP request body. It also controls how the +// gRPC response message is mapped to the HTTP response body. `HttpRule` is +// typically specified as an `google.api.http` annotation on the gRPC method. +// +// Each mapping specifies a URL path template and an HTTP method. The path +// template may refer to one or more fields in the gRPC request message, as long +// as each field is a non-repeated field with a primitive (non-message) type. +// The path template controls how fields of the request message are mapped to +// the URL path. +// +// Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/{name=messages/*}" +// }; +// } +// } +// message GetMessageRequest { +// string name = 1; // Mapped to URL path. +// } +// message Message { +// string text = 1; // The resource content. +// } +// +// This enables an HTTP REST to gRPC mapping as below: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456` | `GetMessage(name: "messages/123456")` +// +// Any fields in the request message which are not bound by the path template +// automatically become HTTP query parameters if there is no HTTP request body. +// For example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get:"/v1/messages/{message_id}" +// }; +// } +// } +// message GetMessageRequest { +// message SubMessage { +// string subfield = 1; +// } +// string message_id = 1; // Mapped to URL path. +// int64 revision = 2; // Mapped to URL query parameter `revision`. +// SubMessage sub = 3; // Mapped to URL query parameter `sub.subfield`. +// } +// +// This enables a HTTP JSON to RPC mapping as below: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456?revision=2&sub.subfield=foo` | +// `GetMessage(message_id: "123456" revision: 2 sub: SubMessage(subfield: +// "foo"))` +// +// Note that fields which are mapped to URL query parameters must have a +// primitive type or a repeated primitive type or a non-repeated message type. +// In the case of a repeated type, the parameter can be repeated in the URL +// as `...?param=A¶m=B`. In the case of a message type, each field of the +// message is mapped to a separate parameter, such as +// `...?foo.a=A&foo.b=B&foo.c=C`. +// +// For HTTP methods that allow a request body, the `body` field +// specifies the mapping. Consider a REST update method on the +// message resource collection: +// +// service Messaging { +// rpc UpdateMessage(UpdateMessageRequest) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "message" +// }; +// } +// } +// message UpdateMessageRequest { +// string message_id = 1; // mapped to the URL +// Message message = 2; // mapped to the body +// } +// +// The following HTTP JSON to RPC mapping is enabled, where the +// representation of the JSON in the request body is determined by +// protos JSON encoding: +// +// HTTP | gRPC +// -----|----- +// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id: +// "123456" message { text: "Hi!" })` +// +// The special name `*` can be used in the body mapping to define that +// every field not bound by the path template should be mapped to the +// request body. This enables the following alternative definition of +// the update method: +// +// service Messaging { +// rpc UpdateMessage(Message) returns (Message) { +// option (google.api.http) = { +// patch: "/v1/messages/{message_id}" +// body: "*" +// }; +// } +// } +// message Message { +// string message_id = 1; +// string text = 2; +// } +// +// +// The following HTTP JSON to RPC mapping is enabled: +// +// HTTP | gRPC +// -----|----- +// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id: +// "123456" text: "Hi!")` +// +// Note that when using `*` in the body mapping, it is not possible to +// have HTTP parameters, as all fields not bound by the path end in +// the body. This makes this option more rarely used in practice when +// defining REST APIs. The common usage of `*` is in custom methods +// which don't use the URL at all for transferring data. +// +// It is possible to define multiple HTTP methods for one RPC by using +// the `additional_bindings` option. Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/messages/{message_id}" +// additional_bindings { +// get: "/v1/users/{user_id}/messages/{message_id}" +// } +// }; +// } +// } +// message GetMessageRequest { +// string message_id = 1; +// string user_id = 2; +// } +// +// This enables the following two alternative HTTP JSON to RPC mappings: +// +// HTTP | gRPC +// -----|----- +// `GET /v1/messages/123456` | `GetMessage(message_id: "123456")` +// `GET /v1/users/me/messages/123456` | `GetMessage(user_id: "me" message_id: +// "123456")` +// +// ## Rules for HTTP mapping +// +// 1. Leaf request fields (recursive expansion nested messages in the request +// message) are classified into three categories: +// - Fields referred by the path template. They are passed via the URL path. +// - Fields referred by the [HttpRule.body][google.api.HttpRule.body]. They +// are passed via the HTTP +// request body. +// - All other fields are passed via the URL query parameters, and the +// parameter name is the field path in the request message. A repeated +// field can be represented as multiple query parameters under the same +// name. +// 2. If [HttpRule.body][google.api.HttpRule.body] is "*", there is no URL +// query parameter, all fields +// are passed via URL path and HTTP request body. +// 3. If [HttpRule.body][google.api.HttpRule.body] is omitted, there is no HTTP +// request body, all +// fields are passed via URL path and URL query parameters. +// +// ### Path template syntax +// +// Template = "/" Segments [ Verb ] ; +// Segments = Segment { "/" Segment } ; +// Segment = "*" | "**" | LITERAL | Variable ; +// Variable = "{" FieldPath [ "=" Segments ] "}" ; +// FieldPath = IDENT { "." IDENT } ; +// Verb = ":" LITERAL ; +// +// The syntax `*` matches a single URL path segment. The syntax `**` matches +// zero or more URL path segments, which must be the last part of the URL path +// except the `Verb`. +// +// The syntax `Variable` matches part of the URL path as specified by its +// template. A variable template must not contain other variables. If a variable +// matches a single path segment, its template may be omitted, e.g. `{var}` +// is equivalent to `{var=*}`. +// +// The syntax `LITERAL` matches literal text in the URL path. If the `LITERAL` +// contains any reserved character, such characters should be percent-encoded +// before the matching. +// +// If a variable contains exactly one path segment, such as `"{var}"` or +// `"{var=*}"`, when such a variable is expanded into a URL path on the client +// side, all characters except `[-_.~0-9a-zA-Z]` are percent-encoded. The +// server side does the reverse decoding. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{var}`. +// +// If a variable contains multiple path segments, such as `"{var=foo/*}"` +// or `"{var=**}"`, when such a variable is expanded into a URL path on the +// client side, all characters except `[-_.~/0-9a-zA-Z]` are percent-encoded. +// The server side does the reverse decoding, except "%2F" and "%2f" are left +// unchanged. Such variables show up in the +// [Discovery +// Document](https://developers.google.com/discovery/v1/reference/apis) as +// `{+var}`. +// +// ## Using gRPC API Service Configuration +// +// gRPC API Service Configuration (service config) is a configuration language +// for configuring a gRPC service to become a user-facing product. The +// service config is simply the YAML representation of the `google.api.Service` +// proto message. +// +// As an alternative to annotating your proto file, you can configure gRPC +// transcoding in your service config YAML files. You do this by specifying a +// `HttpRule` that maps the gRPC method to a REST endpoint, achieving the same +// effect as the proto annotation. This can be particularly useful if you +// have a proto that is reused in multiple services. Note that any transcoding +// specified in the service config will override any matching transcoding +// configuration in the proto. +// +// Example: +// +// http: +// rules: +// # Selects a gRPC method and applies HttpRule to it. +// - selector: example.v1.Messaging.GetMessage +// get: /v1/messages/{message_id}/{sub.subfield} +// +// ## Special notes +// +// When gRPC Transcoding is used to map a gRPC to JSON REST endpoints, the +// proto to JSON conversion must follow the [proto3 +// specification](https://developers.google.com/protocol-buffers/docs/proto3#json). +// +// While the single segment variable follows the semantics of +// [RFC 6570](https://tools.ietf.org/html/rfc6570) Section 3.2.2 Simple String +// Expansion, the multi segment variable **does not** follow RFC 6570 Section +// 3.2.3 Reserved Expansion. The reason is that the Reserved Expansion +// does not expand special characters like `?` and `#`, which would lead +// to invalid URLs. As the result, gRPC Transcoding uses a custom encoding +// for multi segment variables. +// +// The path variables **must not** refer to any repeated or mapped field, +// because client libraries are not capable of handling such variable expansion. +// +// The path variables **must not** capture the leading "/" character. The reason +// is that the most common use case "{var}" does not capture the leading "/" +// character. For consistency, all path variables must share the same behavior. +// +// Repeated message fields must not be mapped to URL query parameters, because +// no client library can support such complicated mapping. +// +// If an API needs to use a JSON array for request or response body, it can map +// the request or response body to a repeated field. However, some gRPC +// Transcoding implementations may not support this feature. +message HttpRule { + // Selects a method to which this rule applies. + // + // Refer to [selector][google.api.DocumentationRule.selector] for syntax + // details. + string selector = 1; + + // Determines the URL pattern is matched by this rules. This pattern can be + // used with any of the {get|put|post|delete|patch} methods. A custom method + // can be defined using the 'custom' field. + oneof pattern { + // Maps to HTTP GET. Used for listing and getting information about + // resources. + string get = 2; + + // Maps to HTTP PUT. Used for replacing a resource. + string put = 3; + + // Maps to HTTP POST. Used for creating a resource or performing an action. + string post = 4; + + // Maps to HTTP DELETE. Used for deleting a resource. + string delete = 5; + + // Maps to HTTP PATCH. Used for updating a resource. + string patch = 6; + + // The custom pattern is used for specifying an HTTP method that is not + // included in the `pattern` field, such as HEAD, or "*" to leave the + // HTTP method unspecified for this rule. The wild-card rule is useful + // for services that provide content to Web (HTML) clients. + CustomHttpPattern custom = 8; + } + + // The name of the request field whose value is mapped to the HTTP request + // body, or `*` for mapping all request fields not captured by the path + // pattern to the HTTP body, or omitted for not having any HTTP request body. + // + // NOTE: the referred field must be present at the top-level of the request + // message type. + string body = 7; + + // Optional. The name of the response field whose value is mapped to the HTTP + // response body. When omitted, the entire response message will be used + // as the HTTP response body. + // + // NOTE: The referred field must be present at the top-level of the response + // message type. + string response_body = 12; + + // Additional HTTP bindings for the selector. Nested bindings must + // not contain an `additional_bindings` field themselves (that is, + // the nesting may only be one level deep). + repeated HttpRule additional_bindings = 11; +} + +// A custom pattern is used for defining custom HTTP verb. +message CustomHttpPattern { + // The name of this custom HTTP verb. + string kind = 1; + + // The path matched by this custom verb. + string path = 2; +} \ No newline at end of file diff --git a/Api/Protos/v1/article.proto b/SSTAlumniAssociation.Api/Protos/v1/article.proto similarity index 73% rename from Api/Protos/v1/article.proto rename to SSTAlumniAssociation.Api/Protos/v1/article.proto index e41680f..b363e8c 100644 --- a/Api/Protos/v1/article.proto +++ b/SSTAlumniAssociation.Api/Protos/v1/article.proto @@ -2,7 +2,7 @@ syntax = "proto3"; import "google/protobuf/field_mask.proto"; import "google/protobuf/empty.proto"; -import "google/api/annotations.proto"; +import "Protos/google/api/annotations.proto"; package article.v1; @@ -10,10 +10,10 @@ message Article { string id = 1; string title = 2; string description = 3; - string heroImageAlta = 4; - string heroImageUrl = 5; - string ctaTitle = 6; - string ctaUrl = 7; + string hero_image_alt= 4; + string hero_image_url = 5; + string cta_title = 6; + string cta_url = 7; } // Article service @@ -28,8 +28,8 @@ service ArticleService { } message ListArticlesRequest { - int32 pageSize = 1; - string pageToken = 2; + int32 page_size = 1; + string page_token = 2; } message ListArticlesResponse { diff --git a/Api/Protos/v1/auth.proto b/SSTAlumniAssociation.Api/Protos/v1/auth.proto similarity index 58% rename from Api/Protos/v1/auth.proto rename to SSTAlumniAssociation.Api/Protos/v1/auth.proto index 0b982f3..5c0d2fd 100644 --- a/Api/Protos/v1/auth.proto +++ b/SSTAlumniAssociation.Api/Protos/v1/auth.proto @@ -2,19 +2,25 @@ syntax = "proto3"; import "google/protobuf/field_mask.proto"; import "google/protobuf/empty.proto"; -import "google/api/annotations.proto"; +import "Protos/google/api/annotations.proto"; package auth.v1; // Auth service service AuthService { - // Verify a user + // Verify whether a user is a registered SSTAA member rpc VerifyUser (VerifyUserRequest) returns (VerifyUserResponse) { option (google.api.http) = { post: "/v1/auth/verify" body: "*" }; }; + + rpc WhoAmI (WhoAmIRequest) returns (WhoAmIResponse) { + option (google.api.http) = { + get: "/v1/auth/whoami" + }; + } } message VerifyUserRequest { @@ -24,4 +30,11 @@ message VerifyUserRequest { message VerifyUserResponse { string id = 1; bool linked = 2; -} \ No newline at end of file +} + +message WhoAmIRequest { +} + +message WhoAmIResponse { + string id = 1; +} diff --git a/Api/Protos/v1/event.proto b/SSTAlumniAssociation.Api/Protos/v1/event.proto similarity index 89% rename from Api/Protos/v1/event.proto rename to SSTAlumniAssociation.Api/Protos/v1/event.proto index a5b3239..9a6adff 100644 --- a/Api/Protos/v1/event.proto +++ b/SSTAlumniAssociation.Api/Protos/v1/event.proto @@ -3,7 +3,7 @@ syntax = "proto3"; import "google/protobuf/field_mask.proto"; import "google/protobuf/timestamp.proto"; import "google/protobuf/empty.proto"; -import "google/api/annotations.proto"; +import "Protos/google/api/annotations.proto"; import "Protos/v1/user.proto"; @@ -15,13 +15,13 @@ message Event { string description = 3; string location = 4; string badgeImage = 5; - google.protobuf.Timestamp startDateTime = 6; - google.protobuf.Timestamp endDateTime = 7; + google.protobuf.Timestamp start_date_time = 6; + google.protobuf.Timestamp end_date_time = 7; repeated EventUser attendees = 8; } message EventUser { - string admissionKey = 1; + string admission_key = 1; user.v1.User user = 2; } @@ -80,8 +80,8 @@ service EventService { } message ListEventsRequest { - int32 pageSize = 1; - string pageToken = 2; + int32 page_size = 1; + string page_token = 2; } message ListEventsResponse { @@ -102,7 +102,7 @@ message CreateEventRequest { message UpdateEventRequest { Event event = 1; - google.protobuf.FieldMask updateMask = 2; + google.protobuf.FieldMask update_mask = 2; } message DeleteEventRequest { diff --git a/SSTAlumniAssociation.Api/Protos/v1/user.proto b/SSTAlumniAssociation.Api/Protos/v1/user.proto new file mode 100644 index 0000000..ea8916f --- /dev/null +++ b/SSTAlumniAssociation.Api/Protos/v1/user.proto @@ -0,0 +1,160 @@ +syntax = "proto3"; + +import "google/protobuf/field_mask.proto"; +import "google/protobuf/empty.proto"; +import "Protos/google/api/annotations.proto"; + +package user.v1; + +message User { + string id = 1; + string name = 2; + string email = 3; + string firebase_id = 4; + + oneof user_type { + Member member = 100; + Employee employee = 101; + ServiceAccount service_account = 102; + SystemAdmin system_admin = 103; + } +} + +enum Membership { + Exco = 0; + Associate = 1; + Affiliate = 2; + Ordinary = 3; + Revoked = 4; +} + +message Member { + Membership membership = 1; + string member_id = 2; + + oneof member_type { + AlumniMember alumni_member = 3; + EmployeeMember employee_member = 4; + } +} + +message AlumniMember { + // If a member is an associate and have never graduated from SST, this field will be empty. + optional int32 graduation_year = 1; +} + +message EmployeeMember { + // If a member was an ex-student of SST, this field will contain their graduation year. + optional int32 graduation_year = 1; +} + +enum ServiceAccountType { + GuardHouse = 0; +} + +message ServiceAccount { + ServiceAccountType service_account_type = 1; +} + +message SystemAdmin { +} + +message Employee { +} + +// User service +service UserService { + // List all users, restricted to EXCO + rpc ListUsers (ListUsersRequest) returns (ListUsersResponse) { + option (google.api.http) = { + get: "/v1/users" + }; + }; + + // Get user information, restricted to authenticated users + rpc GetUser (GetUserRequest) returns (User) { + option (google.api.http) = { + get: "/v1/users/{id}" + }; + }; + + // Bind user to a Firebase ID. Although this can also be done in the UpdateUser route, due to the different + // permission requirement for updating FirebaseId, it would introduce a partial success state for the endpoint (where + // only the FirebaseId is updated of all fields provided in the UpdateMask). Therefore, a separate route exists for + // this specific use case. This route should be called by end-users only (not admins) as it will bind the user ID + // provided to the current authenticated user. + rpc BindUser (BindUserRequest) returns (User) { + option (google.api.http) = { + post: "/v1/users/{id}/bind" + }; + }; + + // Create users + rpc CreateUser (CreateUserRequest) returns (User) { + option (google.api.http) = { + post: "/v1/users" + body: "*" + }; + } + + // Batch create users + rpc BatchCreateUsers (BatchCreateUsersRequest) returns (BatchCreateUsersResponse) { + option (google.api.http) = { + post: "/v1/users:batchCreate" + body: "*" + }; + } + + // Update user + rpc UpdateUser (UpdateUserRequest) returns (User) { + option (google.api.http) = { + patch: "/v1/users/{user.id}" + body: "user" + }; + } + + // Delete user + rpc DeleteUser (DeleteUserRequest) returns (google.protobuf.Empty) { + option (google.api.http) = { + delete: "/v1/users/{id}" + }; + } +} + +message ListUsersRequest { + int32 page_size = 1; + string page_token = 2; +} + +message ListUsersResponse { + repeated User users = 1; +} + +message GetUserRequest { + string id = 1; +} + +message BindUserRequest { + string id = 1; +} + +message CreateUserRequest { + User user = 1; +} + +message BatchCreateUsersRequest { + repeated CreateUserRequest requests = 1; +} + +message BatchCreateUsersResponse { + repeated User users = 1; +} + +message UpdateUserRequest { + User user = 1; + google.protobuf.FieldMask update_mask = 2; +} + +message DeleteUserRequest { + string id = 1; +} diff --git a/SSTAlumniAssociation.Api/SSTAlumniAssociation.Api.csproj b/SSTAlumniAssociation.Api/SSTAlumniAssociation.Api.csproj new file mode 100644 index 0000000..ad5b524 --- /dev/null +++ b/SSTAlumniAssociation.Api/SSTAlumniAssociation.Api.csproj @@ -0,0 +1,48 @@ + + + + net8.0 + enable + enable + true + Linux + true + true + + + + + .dockerignore + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/SSTAlumniAssociation.Api/Services/V1/ArticleService.cs b/SSTAlumniAssociation.Api/Services/V1/ArticleService.cs new file mode 100644 index 0000000..499970e --- /dev/null +++ b/SSTAlumniAssociation.Api/Services/V1/ArticleService.cs @@ -0,0 +1,16 @@ +using Article.V1; +using Grpc.Core; +using Microsoft.AspNetCore.Authorization; +using SSTAlumniAssociation.Api.Authorization; + +namespace SSTAlumniAssociation.Api.Services.V1; + +[AuthorizeMember] +public class ArticleServiceV1 : ArticleService.ArticleServiceBase +{ + public override async Task ListArticles(ListArticlesRequest request, + ServerCallContext context) + { + return await base.ListArticles(request, context); + } +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Services/V1/AuthService.cs b/SSTAlumniAssociation.Api/Services/V1/AuthService.cs new file mode 100644 index 0000000..e36806f --- /dev/null +++ b/SSTAlumniAssociation.Api/Services/V1/AuthService.cs @@ -0,0 +1,44 @@ +using SSTAlumniAssociation.Api.Extensions; +using Auth.V1; +using Grpc.Core; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; +using SSTAlumniAssociation.Api.Context; + +namespace SSTAlumniAssociation.Api.Services.V1; + +[Authorize] +public class AuthServiceV1(AppDbContext dbContext) : AuthService.AuthServiceBase +{ + [AllowAnonymous] + public override async Task VerifyUser(VerifyUserRequest request, ServerCallContext context) + { + var user = await dbContext.Users.Where(u => u.Email == request.Email).SingleOrDefaultAsync(); + if (user is null) + { + throw new RpcException(new Status(StatusCode.NotFound, "User does not exist.")); + } + + return new VerifyUserResponse + { + Id = user.Id.ToString(), + Linked = !string.IsNullOrWhiteSpace(user.FirebaseId) + }; + } + + public override async Task WhoAmI(WhoAmIRequest request, ServerCallContext context) + { + var id = context.GetHttpContext().User.Claims.GetNameIdentifierGuid(); + + var user = await dbContext.Users.FindAsync(id); + if (user is null) + { + throw new RpcException(new Status(StatusCode.NotFound, "User does not exist.")); + } + + return new WhoAmIResponse + { + Id = user.Id.ToString() + }; + } +} \ No newline at end of file diff --git a/Api/Services/V1/EventService.cs b/SSTAlumniAssociation.Api/Services/V1/EventService.cs similarity index 81% rename from Api/Services/V1/EventService.cs rename to SSTAlumniAssociation.Api/Services/V1/EventService.cs index 0fb9f12..ff7e856 100644 --- a/Api/Services/V1/EventService.cs +++ b/SSTAlumniAssociation.Api/Services/V1/EventService.cs @@ -1,11 +1,13 @@ -using Auth.V1; using Event.V1; using Google.Protobuf.WellKnownTypes; using Grpc.Core; +using Microsoft.AspNetCore.Authorization; +using SSTAlumniAssociation.Api.Authorization; -namespace Api.Services.V1; +namespace SSTAlumniAssociation.Api.Services.V1; -public class EventServiceV1: EventService.EventServiceBase +[Authorize] +public class EventServiceV1 : EventService.EventServiceBase { public override Task ListEvents(ListEventsRequest request, ServerCallContext context) { @@ -22,21 +24,25 @@ public override Task ListEvents(ListEventsRequest request, S return base.GetAdmission(request, context); } + [AuthorizeAdmin] public override Task UpdateEvent(UpdateEventRequest request, ServerCallContext context) { return base.UpdateEvent(request, context); } + [AuthorizeAdmin] public override Task DeleteEvent(DeleteEventRequest request, ServerCallContext context) { return base.DeleteEvent(request, context); } + [AuthorizeAdmin] public override Task AddAttendee(AddAttendeeRequest request, ServerCallContext context) { return base.AddAttendee(request, context); } + [AuthorizeAdmin] public override Task BatchAddAttendees(BatchAddAttendeesRequest request, ServerCallContext context) { return base.BatchAddAttendees(request, context); diff --git a/SSTAlumniAssociation.Api/Services/V1/UserService.cs b/SSTAlumniAssociation.Api/Services/V1/UserService.cs new file mode 100644 index 0000000..360c226 --- /dev/null +++ b/SSTAlumniAssociation.Api/Services/V1/UserService.cs @@ -0,0 +1,268 @@ +using System.Security.Claims; +using SSTAlumniAssociation.Api.Extensions; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Microsoft.AspNetCore.Authorization; +using SSTAlumniAssociation.Api.Authorization; +using SSTAlumniAssociation.Api.Authorization.OwnerOrAdmin; +using SSTAlumniAssociation.Api.Context; +using SSTAlumniAssociation.Api.Entities; +using User.V1; +using Employee = SSTAlumniAssociation.Api.Entities.Employee; +using EmployeeMember = SSTAlumniAssociation.Api.Entities.EmployeeMember; +using SystemAdmin = SSTAlumniAssociation.Api.Entities.SystemAdmin; + +namespace SSTAlumniAssociation.Api.Services.V1; + +/// +public class UserServiceV1( + ILogger logger, + IAuthorizationService authorizationService, + AppDbContext dbContext) : UserService.UserServiceBase +{ + // [Authorize] + // public override async Task BindUser(BindUserRequest request, ServerCallContext context) + // { + // var email = context.GetHttpContext().User.Claims.SingleOrDefault(c => c.Type == ClaimTypes.Email); + // if (email is null) + // { + // throw new RpcException(new Status(StatusCode.PermissionDenied, "Weird email.")); + // } + // + // var firebaseId = context.GetHttpContext().User.Claims.SingleOrDefault(c => c.Type == "user_id"); + // if (firebaseId is null) + // { + // throw new RpcException(new Status(StatusCode.Internal, "Firebase ID does not exist in token.")); + // } + // + // var user = await dbContext.Users.FindAsync(request.Id); + // if (user is null || user.Email != email.Value) + // { + // throw new RpcException(new Status(StatusCode.PermissionDenied, "Mailbox issue, wrong email.")); + // } + // + // if (!string.IsNullOrWhiteSpace(user.FirebaseId)) + // { + // throw new RpcException(new Status(StatusCode.FailedPrecondition, "Firebase ID already set for user.")); + // } + // + // user.FirebaseId = firebaseId.Value; + // + // await dbContext.SaveChangesAsync(); + // + // return user.ToGrpcUser(); + // } + // + // [AuthorizeMembers(UserMemberType.Exco)] + // public override async Task CreateUser(CreateUserRequest request, ServerCallContext context) + // { + // var user = await dbContext.Users.AddAsync(request.User.ToUser()); + // await dbContext.SaveChangesAsync(); + // return user.Entity.ToGrpcUser(); + // } + // + // [AuthorizeMembers(UserMemberType.Exco)] + // public override async Task BatchCreateUsers(BatchCreateUsersRequest request, + // ServerCallContext context) + // { + // await using var txn = await dbContext.Database.BeginTransactionAsync(); + // + // var users = new List(); + // foreach (var r in request.Requests) + // { + // var us = r.User.ToUser(); + // var u = await dbContext.Users.FindAsync(us.Id); + // if (u is null) + // { + // users.Add(us); + // await dbContext.Users.AddAsync(us); + // } + // else + // { + // u.MemberId = us.MemberId; + // } + // } + // + // await txn.CommitAsync(); + // + // await dbContext.SaveChangesAsync(); + // + // return new BatchCreateUsersResponse + // { + // Users = { users.Select(u => u.ToGrpcUser()) } + // }; + // } + // + // [AuthorizeMembers(UserMemberType.Exco)] + // public override async Task UpdateUser(UpdateUserRequest request, ServerCallContext context) + // { + // var user = await dbContext.Users.SingleAsync(c => c.Id.ToString() == request.User.Id); + // if (user is null) + // { + // throw new RpcException(new Status(StatusCode.NotFound, "User disappeared into the abyss.")); + // } + // + // var diff = new User.V1.User(); + // request.UpdateMask.Merge(request.User, diff); + // + // if (request.UpdateMask.Paths.Contains("memberId")) + // { + // user.MemberId = diff.MemberId; + // } + // + // if (request.UpdateMask.Paths.Contains("name")) + // { + // user.Name = diff.Name; + // } + // + // if (request.UpdateMask.Paths.Contains("email")) + // { + // user.Email = diff.Email; + // } + // + // if (request.UpdateMask.Paths.Contains("graduationYear")) + // { + // user.GraduationYear = diff.GraduationYear; + // } + // + // if (request.UpdateMask.Paths.Contains("memberType")) + // { + // user.MemberType = diff.MemberType.ToUserMemberType(); + // } + // + // await dbContext.SaveChangesAsync(); + // + // return user.ToGrpcUser(); + // } + // + // [AuthorizeMembers(UserMemberType.Exco)] + // public override async Task DeleteUser(DeleteUserRequest request, ServerCallContext context) + // { + // var id = context.GetHttpContext().User.Claims.Single(c => c.Type == ClaimTypes.NameIdentifier); + // if (request.Id == id.Value) + // { + // throw new RpcException(new Status(StatusCode.FailedPrecondition, "Cannot suicide.")); + // } + // + // var user = await dbContext.Users.SingleAsync(c => c.Id.ToString() == request.Id); + // if (user is null) + // { + // throw new RpcException(new Status(StatusCode.NotFound, "User disappeared into the abyss.")); + // } + // + // dbContext.Users.Remove(user); + // + // return new Empty(); + // } + + [AuthorizeAdmin] + public override async Task ListUsers(ListUsersRequest request, ServerCallContext context) + { + return new ListUsersResponse + { + Users = + { + dbContext.Users.Select(u => u.ToGrpcUser()) + } + }; + } + + [Authorize] + public override async Task GetUser(GetUserRequest request, ServerCallContext context) + { + var authorized = + await authorizationService.AuthorizeAsync(context.GetHttpContext().User, request.Id, + OwnerOrAdminOperations.Read); + if (!authorized.Succeeded) + { + throw new RpcException(new Status(StatusCode.PermissionDenied, + "User is not authorized to peak at others.")); + } + + var user = await dbContext.Users.FindAsync(Guid.Parse(request.Id)); + if (user is null) + { + throw new RpcException(new Status(StatusCode.NotFound, "User does not exist.")); + } + + return user.ToGrpcUser(); + } + + [Authorize] + public override async Task BindUser(BindUserRequest request, ServerCallContext context) + { + var email = context.GetHttpContext().User.Claims.SingleOrDefault(c => c.Type == ClaimTypes.Email); + if (email is null) + { + throw new RpcException(new Status(StatusCode.PermissionDenied, "Email does not exist in token.")); + } + + var firebaseId = context.GetHttpContext().User.Claims.SingleOrDefault(c => c.Type == "user_id"); + if (firebaseId is null) + { + throw new RpcException(new Status(StatusCode.Internal, "Firebase UID does not exist in token.")); + } + + var user = await dbContext.Users.FindAsync(Guid.Parse(request.Id)); + if (user is null || user.Email != email.Value) + { + throw new RpcException(new Status(StatusCode.PermissionDenied, "")); + } + + if (!string.IsNullOrWhiteSpace(user.FirebaseId)) + { + throw new RpcException(new Status(StatusCode.FailedPrecondition, "Firebase ID already set for user.")); + } + + user.FirebaseId = firebaseId.Value; + + await dbContext.SaveChangesAsync(); + + return user.ToGrpcUser(); + } + + [AuthorizeAdmin] + public override async Task CreateUser(CreateUserRequest request, ServerCallContext context) + { + var user = request.User.ToUser(); + + switch (user) + { + case Entities.Employee: + case Entities.AlumniMember: + case Entities.EmployeeMember: + case Entities.ServiceAccount: + case SystemAdmin: + default: + throw new Exception("Unrecognized user type."); + } + + // var user = await dbContext.Users.AddAsync(request.User.ToUser()); + // await dbContext.SaveChangesAsync(); + // return user.Entity.ToGrpcUser(); + } + + public override async Task BatchCreateUsers(BatchCreateUsersRequest request, + ServerCallContext context) + { + return await base.BatchCreateUsers(request, context); + } + + [Authorize] + public override async Task UpdateUser(UpdateUserRequest request, ServerCallContext context) + { + var diff = new User.V1.User(); + request.UpdateMask.Merge(request.User, diff); + + if (request.UpdateMask.Paths.Contains("firebase_id")) + { + } + + return await base.UpdateUser(request, context); + } + + public override async Task DeleteUser(DeleteUserRequest request, ServerCallContext context) + { + return await base.DeleteUser(request, context); + } +} \ No newline at end of file diff --git a/SSTAlumniAssociation.Api/Services/V1/UserValidator.cs b/SSTAlumniAssociation.Api/Services/V1/UserValidator.cs new file mode 100644 index 0000000..58abf5b --- /dev/null +++ b/SSTAlumniAssociation.Api/Services/V1/UserValidator.cs @@ -0,0 +1,39 @@ +using FluentValidation; +using User.V1; + +namespace SSTAlumniAssociation.Api.Services.V1; + +public class CreateUserRequestValidator : AbstractValidator +{ + public CreateUserRequestValidator() + { + RuleFor(u => u.User).NotEmpty(); + + When(u => u.User != null, () => + { + RuleFor(u => u.User.Id) + .Must(id => Guid.TryParse(id, out _)) + .WithMessage("Invalid ID provided for User."); + RuleFor(u => u.User.Name).MinimumLength(1); + RuleFor(u => u.User.Email).EmailAddress(); + + When(u => u.User.Member != null, () => + { + RuleFor(u => u.User.Member.AlumniMember) + .Must(u => u.HasGraduationYear && u.GraduationYear >= 2013) + .WithMessage("Graduation year must be after 2013.") + .When(u => u.User.Member.AlumniMember != null); + + RuleFor(u => u.User.Member.EmployeeMember) + .Must(u => u.HasGraduationYear && u.GraduationYear >= 2013) + .WithMessage("Graduation year must be after 2013.") + .When(u => u.User.Member.EmployeeMember != null); + + // In the case of an associate member, they may have studied in SST but not graduated. + RuleFor(u => u.User.Member) + .Must(u => !u.AlumniMember.HasGraduationYear && u.Membership == Membership.Associate) + .WithMessage("Alumni membership can only be associate if there is no graduation year."); + }); + }); + } +} \ No newline at end of file diff --git a/Api/appsettings.Development.json b/SSTAlumniAssociation.Api/appsettings.Development.json similarity index 100% rename from Api/appsettings.Development.json rename to SSTAlumniAssociation.Api/appsettings.Development.json diff --git a/Api/appsettings.json b/SSTAlumniAssociation.Api/appsettings.json similarity index 100% rename from Api/appsettings.json rename to SSTAlumniAssociation.Api/appsettings.json diff --git a/SSTAlumniAssociation.Api/buf.yaml b/SSTAlumniAssociation.Api/buf.yaml new file mode 100644 index 0000000..1a51945 --- /dev/null +++ b/SSTAlumniAssociation.Api/buf.yaml @@ -0,0 +1,7 @@ +version: v1 +breaking: + use: + - FILE +lint: + use: + - DEFAULT diff --git a/SSTAlumniAssociation.sln b/SSTAlumniAssociation.sln index cb9318c..f088e88 100644 --- a/SSTAlumniAssociation.sln +++ b/SSTAlumniAssociation.sln @@ -1,6 +1,6 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "Api\Api.csproj", "{53532F55-4A12-4F0F-82DB-CF7A956E0B82}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSTAlumniAssociation.Api", "SSTAlumniAssociation.Api\SSTAlumniAssociation.Api.csproj", "{53532F55-4A12-4F0F-82DB-CF7A956E0B82}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..9056f20 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,12 @@ +version: v1 +managed: + enabled: true +plugins: + - plugin: buf.build/connectrpc/es + out: gen/es + - plugin: buf.build/bufbuild/es + out: gen/es + - plugin: buf.build/connectrpc/swift + out: gen/swift + - plugin: buf.build/apple/swift + out: gen/swift