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