From dd87b46bb6a4d44dc0bf9bd2ed7e7dac431f700b Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 02:49:10 +0900 Subject: [PATCH 01/11] feat(identity): add project read endpoints --- internal/api/identity/project.go | 29 ++++++++++++ internal/api/identity/project_dto.go | 18 ++++++++ internal/api/identity/project_test.go | 63 +++++++++++++++++++++++++++ internal/api/identity/router.go | 2 + internal/app/identity/project.go | 29 ++++++++++++ 5 files changed, 141 insertions(+) create mode 100644 internal/api/identity/project.go create mode 100644 internal/api/identity/project_dto.go create mode 100644 internal/api/identity/project_test.go create mode 100644 internal/app/identity/project.go diff --git a/internal/api/identity/project.go b/internal/api/identity/project.go new file mode 100644 index 0000000..0e079f2 --- /dev/null +++ b/internal/api/identity/project.go @@ -0,0 +1,29 @@ +package identity + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listProjects(w http.ResponseWriter, r *http.Request) { + respond.JSON(w, http.StatusOK, projectsResponse{ + Projects: h.service.Projects(), + Links: identityLinks{ + Self: h.baseURL(r) + "/identity/v3/projects", + }, + }) +} + +func (h Handler) getProject(w http.ResponseWriter, r *http.Request) { + project, ok := h.service.ProjectByID(chi.URLParam(r, "project_id")) + if !ok { + respond.Error(w, http.StatusNotFound, "project not found") + return + } + + respond.JSON(w, http.StatusOK, projectResponse{ + Project: project, + }) +} diff --git a/internal/api/identity/project_dto.go b/internal/api/identity/project_dto.go new file mode 100644 index 0000000..a847612 --- /dev/null +++ b/internal/api/identity/project_dto.go @@ -0,0 +1,18 @@ +package identity + +import "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects" + +type projectsResponse struct { + Projects []projects.Project `json:"projects"` + Links identityLinks `json:"links"` +} + +type projectResponse struct { + Project projects.Project `json:"project"` +} + +type identityLinks struct { + Self string `json:"self"` + Previous string `json:"previous,omitempty"` + Next string `json:"next,omitempty"` +} diff --git a/internal/api/identity/project_test.go b/internal/api/identity/project_test.go new file mode 100644 index 0000000..e639385 --- /dev/null +++ b/internal/api/identity/project_test.go @@ -0,0 +1,63 @@ +package identity_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/identity" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects" + "github.com/stretchr/testify/suite" +) + +type ProjectSuite struct { + suite.Suite + server *httptest.Server +} + +func TestProjectSuite(t *testing.T) { + suite.Run(t, new(ProjectSuite)) +} + +func (s *ProjectSuite) SetupTest() { + s.server = httptest.NewServer( + identity.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *ProjectSuite) TearDownTest() { + s.server.Close() +} + +func (s *ProjectSuite) TestListProjects() { + pages, err := projects.List( + testhelper.ServiceClient(s.server.URL), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + listed, err := projects.ExtractProjects(pages) + s.Require().NoError(err) + s.Require().Len(listed, 1) + + s.Assert().Equal("demo", listed[0].ID) + s.Assert().Equal("demo", listed[0].Name) + s.Assert().Equal("default", listed[0].DomainID) + s.Assert().True(listed[0].Enabled) +} + +func (s *ProjectSuite) TestGetProject() { + result := projects.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + "demo", + ) + project, err := result.Extract() + s.Require().NoError(err) + s.Require().NotNil(project) + + s.Assert().Equal("demo", project.ID) + s.Assert().Equal("demo", project.Name) + s.Assert().Equal("default", project.DomainID) + s.Assert().True(project.Enabled) +} diff --git a/internal/api/identity/router.go b/internal/api/identity/router.go index 1735aa3..d273e2a 100644 --- a/internal/api/identity/router.go +++ b/internal/api/identity/router.go @@ -33,6 +33,8 @@ func (h Handler) Router() http.Handler { router := chi.NewRouter() router.Get("/", h.version) router.Post("/auth/tokens", h.createToken) + router.Get("/projects", h.listProjects) + router.Get("/projects/{project_id}", h.getProject) return router } diff --git a/internal/app/identity/project.go b/internal/app/identity/project.go new file mode 100644 index 0000000..be802db --- /dev/null +++ b/internal/app/identity/project.go @@ -0,0 +1,29 @@ +package identity + +import "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects" + +func (s Service) Projects() []projects.Project { + return []projects.Project{s.Project()} +} + +func (s Service) Project() projects.Project { + return projects.Project{ + ID: s.config.ProjectID, + Name: s.config.ProjectName, + Description: "Default SandStack project", + DomainID: "default", + Enabled: true, + IsDomain: false, + Tags: []string{}, + Extra: map[string]any{}, + } +} + +func (s Service) ProjectByID(id string) (projects.Project, bool) { + project := s.Project() + if project.ID != id { + return projects.Project{}, false + } + + return project, true +} From 3dd1000abdded4c58257fe3cfdcf1f1fa1371d44 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 02:50:30 +0900 Subject: [PATCH 02/11] feat(identity): add user read endpoints --- internal/api/identity/router.go | 2 + internal/api/identity/user.go | 36 +++++++++++++++++ internal/api/identity/user_dto.go | 38 ++++++++++++++++++ internal/api/identity/user_test.go | 63 ++++++++++++++++++++++++++++++ internal/app/identity/user.go | 34 ++++++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 internal/api/identity/user.go create mode 100644 internal/api/identity/user_dto.go create mode 100644 internal/api/identity/user_test.go create mode 100644 internal/app/identity/user.go diff --git a/internal/api/identity/router.go b/internal/api/identity/router.go index d273e2a..ca2296b 100644 --- a/internal/api/identity/router.go +++ b/internal/api/identity/router.go @@ -35,6 +35,8 @@ func (h Handler) Router() http.Handler { router.Post("/auth/tokens", h.createToken) router.Get("/projects", h.listProjects) router.Get("/projects/{project_id}", h.getProject) + router.Get("/users", h.listUsers) + router.Get("/users/{user_id}", h.getUser) return router } diff --git a/internal/api/identity/user.go b/internal/api/identity/user.go new file mode 100644 index 0000000..81121cb --- /dev/null +++ b/internal/api/identity/user.go @@ -0,0 +1,36 @@ +package identity + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listUsers(w http.ResponseWriter, r *http.Request) { + baseURL := h.baseURL(r) + users := h.service.Users() + responseUsers := make([]user, 0, len(users)) + for _, currentUser := range users { + responseUsers = append(responseUsers, toUser(currentUser, baseURL)) + } + + respond.JSON(w, http.StatusOK, usersResponse{ + Users: responseUsers, + Links: identityLinks{ + Self: baseURL + "/identity/v3/users", + }, + }) +} + +func (h Handler) getUser(w http.ResponseWriter, r *http.Request) { + currentUser, ok := h.service.UserByID(chi.URLParam(r, "user_id")) + if !ok { + respond.Error(w, http.StatusNotFound, "user not found") + return + } + + respond.JSON(w, http.StatusOK, userResponse{ + User: toUser(currentUser, h.baseURL(r)), + }) +} diff --git a/internal/api/identity/user_dto.go b/internal/api/identity/user_dto.go new file mode 100644 index 0000000..29a4e81 --- /dev/null +++ b/internal/api/identity/user_dto.go @@ -0,0 +1,38 @@ +package identity + +import appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" + +type usersResponse struct { + Users []user `json:"users"` + Links identityLinks `json:"links"` +} + +type userResponse struct { + User user `json:"user"` +} + +type user struct { + DefaultProjectID string `json:"default_project_id"` + Description string `json:"description"` + DomainID string `json:"domain_id"` + Enabled bool `json:"enabled"` + ID string `json:"id"` + Links map[string]any `json:"links"` + Name string `json:"name"` + Options map[string]any `json:"options"` +} + +func toUser(source appidentity.User, baseURL string) user { + return user{ + DefaultProjectID: source.DefaultProjectID, + Description: source.Description, + DomainID: source.DomainID, + Enabled: source.Enabled, + ID: source.ID, + Links: map[string]any{ + "self": baseURL + "/identity/v3/users/" + source.ID, + }, + Name: source.Name, + Options: map[string]any{}, + } +} diff --git a/internal/api/identity/user_test.go b/internal/api/identity/user_test.go new file mode 100644 index 0000000..2eb42f3 --- /dev/null +++ b/internal/api/identity/user_test.go @@ -0,0 +1,63 @@ +package identity_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/identity" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/users" + "github.com/stretchr/testify/suite" +) + +type UserSuite struct { + suite.Suite + server *httptest.Server +} + +func TestUserSuite(t *testing.T) { + suite.Run(t, new(UserSuite)) +} + +func (s *UserSuite) SetupTest() { + s.server = httptest.NewServer( + identity.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *UserSuite) TearDownTest() { + s.server.Close() +} + +func (s *UserSuite) TestListUsers() { + pages, err := users.List( + testhelper.ServiceClient(s.server.URL), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + listed, err := users.ExtractUsers(pages) + s.Require().NoError(err) + s.Require().Len(listed, 1) + + s.Assert().Equal("admin", listed[0].ID) + s.Assert().Equal("admin", listed[0].Name) + s.Assert().Equal("demo", listed[0].DefaultProjectID) + s.Assert().True(listed[0].Enabled) +} + +func (s *UserSuite) TestGetUser() { + result := users.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + "admin", + ) + user, err := result.Extract() + s.Require().NoError(err) + s.Require().NotNil(user) + + s.Assert().Equal("admin", user.ID) + s.Assert().Equal("admin", user.Name) + s.Assert().Equal("demo", user.DefaultProjectID) + s.Assert().True(user.Enabled) +} diff --git a/internal/app/identity/user.go b/internal/app/identity/user.go new file mode 100644 index 0000000..a9356cb --- /dev/null +++ b/internal/app/identity/user.go @@ -0,0 +1,34 @@ +package identity + +type User struct { + DefaultProjectID string + Description string + DomainID string + Enabled bool + ID string + Name string +} + +func (s Service) Users() []User { + return []User{s.User()} +} + +func (s Service) User() User { + return User{ + DefaultProjectID: s.config.ProjectID, + Description: "Default SandStack user", + DomainID: "default", + Enabled: true, + ID: s.config.UserID, + Name: s.config.Username, + } +} + +func (s Service) UserByID(id string) (User, bool) { + user := s.User() + if user.ID != id { + return User{}, false + } + + return user, true +} From f116639d9959b065f89cdbafd4808b20031ab654 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 02:51:40 +0900 Subject: [PATCH 03/11] feat(identity): add role read endpoints --- internal/api/identity/role.go | 29 ++++++++++++++ internal/api/identity/role_dto.go | 12 ++++++ internal/api/identity/role_test.go | 61 ++++++++++++++++++++++++++++++ internal/api/identity/router.go | 2 + internal/app/identity/role.go | 28 ++++++++++++++ 5 files changed, 132 insertions(+) create mode 100644 internal/api/identity/role.go create mode 100644 internal/api/identity/role_dto.go create mode 100644 internal/api/identity/role_test.go create mode 100644 internal/app/identity/role.go diff --git a/internal/api/identity/role.go b/internal/api/identity/role.go new file mode 100644 index 0000000..0d3cfc3 --- /dev/null +++ b/internal/api/identity/role.go @@ -0,0 +1,29 @@ +package identity + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listRoles(w http.ResponseWriter, r *http.Request) { + respond.JSON(w, http.StatusOK, rolesResponse{ + Roles: h.service.Roles(), + Links: identityLinks{ + Self: h.baseURL(r) + "/identity/v3/roles", + }, + }) +} + +func (h Handler) getRole(w http.ResponseWriter, r *http.Request) { + role, ok := h.service.RoleByID(chi.URLParam(r, "role_id")) + if !ok { + respond.Error(w, http.StatusNotFound, "role not found") + return + } + + respond.JSON(w, http.StatusOK, roleResponse{ + Role: role, + }) +} diff --git a/internal/api/identity/role_dto.go b/internal/api/identity/role_dto.go new file mode 100644 index 0000000..a854323 --- /dev/null +++ b/internal/api/identity/role_dto.go @@ -0,0 +1,12 @@ +package identity + +import "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" + +type rolesResponse struct { + Roles []roles.Role `json:"roles"` + Links identityLinks `json:"links"` +} + +type roleResponse struct { + Role roles.Role `json:"role"` +} diff --git a/internal/api/identity/role_test.go b/internal/api/identity/role_test.go new file mode 100644 index 0000000..21f76b5 --- /dev/null +++ b/internal/api/identity/role_test.go @@ -0,0 +1,61 @@ +package identity_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/identity" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" + "github.com/stretchr/testify/suite" +) + +type RoleSuite struct { + suite.Suite + server *httptest.Server +} + +func TestRoleSuite(t *testing.T) { + suite.Run(t, new(RoleSuite)) +} + +func (s *RoleSuite) SetupTest() { + s.server = httptest.NewServer( + identity.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *RoleSuite) TearDownTest() { + s.server.Close() +} + +func (s *RoleSuite) TestListRoles() { + pages, err := roles.List( + testhelper.ServiceClient(s.server.URL), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + listed, err := roles.ExtractRoles(pages) + s.Require().NoError(err) + s.Require().Len(listed, 1) + + s.Assert().Equal("admin", listed[0].ID) + s.Assert().Equal("admin", listed[0].Name) + s.Assert().Equal("default", listed[0].DomainID) +} + +func (s *RoleSuite) TestGetRole() { + result := roles.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + "admin", + ) + role, err := result.Extract() + s.Require().NoError(err) + s.Require().NotNil(role) + + s.Assert().Equal("admin", role.ID) + s.Assert().Equal("admin", role.Name) + s.Assert().Equal("default", role.DomainID) +} diff --git a/internal/api/identity/router.go b/internal/api/identity/router.go index ca2296b..5974560 100644 --- a/internal/api/identity/router.go +++ b/internal/api/identity/router.go @@ -37,6 +37,8 @@ func (h Handler) Router() http.Handler { router.Get("/projects/{project_id}", h.getProject) router.Get("/users", h.listUsers) router.Get("/users/{user_id}", h.getUser) + router.Get("/roles", h.listRoles) + router.Get("/roles/{role_id}", h.getRole) return router } diff --git a/internal/app/identity/role.go b/internal/app/identity/role.go new file mode 100644 index 0000000..e474dd3 --- /dev/null +++ b/internal/app/identity/role.go @@ -0,0 +1,28 @@ +package identity + +import "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" + +func (s Service) Roles() []roles.Role { + return []roles.Role{s.Role()} +} + +func (s Service) Role() roles.Role { + return roles.Role{ + DomainID: "default", + ID: s.config.RoleName, + Name: s.config.RoleName, + Description: "Default SandStack role", + Links: map[string]any{}, + Extra: map[string]any{}, + Options: map[roles.Option]any{}, + } +} + +func (s Service) RoleByID(id string) (roles.Role, bool) { + role := s.Role() + if role.ID != id { + return roles.Role{}, false + } + + return role, true +} From 5b91564a6e5bd1bc9bd33f7aca464f936715b0a8 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 02:52:54 +0900 Subject: [PATCH 04/11] feat(identity): add service read endpoints --- internal/api/identity/router.go | 2 + internal/api/identity/service.go | 31 ++++++++++ internal/api/identity/service_dto.go | 12 ++++ internal/api/identity/service_test.go | 77 ++++++++++++++++++++++++ internal/app/identity/service_catalog.go | 31 ++++++++++ 5 files changed, 153 insertions(+) create mode 100644 internal/api/identity/service.go create mode 100644 internal/api/identity/service_dto.go create mode 100644 internal/api/identity/service_test.go create mode 100644 internal/app/identity/service_catalog.go diff --git a/internal/api/identity/router.go b/internal/api/identity/router.go index 5974560..e2cd4da 100644 --- a/internal/api/identity/router.go +++ b/internal/api/identity/router.go @@ -39,6 +39,8 @@ func (h Handler) Router() http.Handler { router.Get("/users/{user_id}", h.getUser) router.Get("/roles", h.listRoles) router.Get("/roles/{role_id}", h.getRole) + router.Get("/services", h.listServices) + router.Get("/services/{service_id}", h.getService) return router } diff --git a/internal/api/identity/service.go b/internal/api/identity/service.go new file mode 100644 index 0000000..a22fb07 --- /dev/null +++ b/internal/api/identity/service.go @@ -0,0 +1,31 @@ +package identity + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listServices(w http.ResponseWriter, r *http.Request) { + baseURL := h.baseURL(r) + respond.JSON(w, http.StatusOK, servicesResponse{ + Services: h.service.Services(baseURL), + Links: identityLinks{ + Self: baseURL + "/identity/v3/services", + }, + }) +} + +func (h Handler) getService(w http.ResponseWriter, r *http.Request) { + baseURL := h.baseURL(r) + service, ok := h.service.ServiceByID(baseURL, chi.URLParam(r, "service_id")) + if !ok { + respond.Error(w, http.StatusNotFound, "service not found") + return + } + + respond.JSON(w, http.StatusOK, serviceResponse{ + Service: service, + }) +} diff --git a/internal/api/identity/service_dto.go b/internal/api/identity/service_dto.go new file mode 100644 index 0000000..6f77ae5 --- /dev/null +++ b/internal/api/identity/service_dto.go @@ -0,0 +1,12 @@ +package identity + +import "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/services" + +type servicesResponse struct { + Services []services.Service `json:"services"` + Links identityLinks `json:"links"` +} + +type serviceResponse struct { + Service services.Service `json:"service"` +} diff --git a/internal/api/identity/service_test.go b/internal/api/identity/service_test.go new file mode 100644 index 0000000..520eb58 --- /dev/null +++ b/internal/api/identity/service_test.go @@ -0,0 +1,77 @@ +package identity_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/identity" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/services" + "github.com/stretchr/testify/suite" +) + +type ServiceSuite struct { + suite.Suite + server *httptest.Server +} + +func TestServiceSuite(t *testing.T) { + suite.Run(t, new(ServiceSuite)) +} + +func (s *ServiceSuite) SetupTest() { + s.server = httptest.NewServer( + identity.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *ServiceSuite) TearDownTest() { + s.server.Close() +} + +func (s *ServiceSuite) TestListServices() { + pages, err := services.List( + testhelper.ServiceClient(s.server.URL), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + listed, err := services.ExtractServices(pages) + s.Require().NoError(err) + + s.Assert().Equal( + map[string]bool{ + "identity": true, + "compute": true, + "network": true, + "image": true, + "volumev3": true, + "placement": true, + }, + identityServiceTypes(listed), + ) +} + +func (s *ServiceSuite) TestGetService() { + result := services.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + "compute", + ) + service, err := result.Extract() + s.Require().NoError(err) + s.Require().NotNil(service) + + s.Assert().Equal("compute", service.ID) + s.Assert().Equal("compute", service.Type) + s.Assert().True(service.Enabled) +} + +func identityServiceTypes(listed []services.Service) map[string]bool { + types := make(map[string]bool, len(listed)) + for _, service := range listed { + types[service.Type] = true + } + + return types +} diff --git a/internal/app/identity/service_catalog.go b/internal/app/identity/service_catalog.go new file mode 100644 index 0000000..b56e21f --- /dev/null +++ b/internal/app/identity/service_catalog.go @@ -0,0 +1,31 @@ +package identity + +import "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/services" + +func (s Service) Services(baseURL string) []services.Service { + catalog := s.Catalog(baseURL) + result := make([]services.Service, 0, len(catalog)) + for _, entry := range catalog { + result = append(result, services.Service{ + ID: entry.ID, + Name: entry.Name, + Description: "SandStack " + entry.Name + " service", + Type: entry.Type, + Enabled: true, + Links: map[string]any{}, + Extra: map[string]any{}, + }) + } + + return result +} + +func (s Service) ServiceByID(baseURL, id string) (services.Service, bool) { + for _, service := range s.Services(baseURL) { + if service.ID == id { + return service, true + } + } + + return services.Service{}, false +} From 490d2af3fdd6088d8c8ff5ba60b9bfe1d59c9ce9 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 02:54:09 +0900 Subject: [PATCH 05/11] feat(identity): add endpoint read endpoints --- internal/api/identity/endpoint.go | 31 +++++++++++ internal/api/identity/endpoint_dto.go | 12 +++++ internal/api/identity/endpoint_test.go | 72 ++++++++++++++++++++++++++ internal/api/identity/router.go | 2 + internal/app/identity/endpoint.go | 40 ++++++++++++++ 5 files changed, 157 insertions(+) create mode 100644 internal/api/identity/endpoint.go create mode 100644 internal/api/identity/endpoint_dto.go create mode 100644 internal/api/identity/endpoint_test.go create mode 100644 internal/app/identity/endpoint.go diff --git a/internal/api/identity/endpoint.go b/internal/api/identity/endpoint.go new file mode 100644 index 0000000..8d79d51 --- /dev/null +++ b/internal/api/identity/endpoint.go @@ -0,0 +1,31 @@ +package identity + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listEndpoints(w http.ResponseWriter, r *http.Request) { + baseURL := h.baseURL(r) + respond.JSON(w, http.StatusOK, endpointsResponse{ + Endpoints: h.service.Endpoints(baseURL), + Links: identityLinks{ + Self: baseURL + "/identity/v3/endpoints", + }, + }) +} + +func (h Handler) getEndpoint(w http.ResponseWriter, r *http.Request) { + baseURL := h.baseURL(r) + endpoint, ok := h.service.EndpointByID(baseURL, chi.URLParam(r, "endpoint_id")) + if !ok { + respond.Error(w, http.StatusNotFound, "endpoint not found") + return + } + + respond.JSON(w, http.StatusOK, endpointResponse{ + Endpoint: endpoint, + }) +} diff --git a/internal/api/identity/endpoint_dto.go b/internal/api/identity/endpoint_dto.go new file mode 100644 index 0000000..a952df5 --- /dev/null +++ b/internal/api/identity/endpoint_dto.go @@ -0,0 +1,12 @@ +package identity + +import "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/endpoints" + +type endpointsResponse struct { + Endpoints []endpoints.Endpoint `json:"endpoints"` + Links identityLinks `json:"links"` +} + +type endpointResponse struct { + Endpoint endpoints.Endpoint `json:"endpoint"` +} diff --git a/internal/api/identity/endpoint_test.go b/internal/api/identity/endpoint_test.go new file mode 100644 index 0000000..443f91e --- /dev/null +++ b/internal/api/identity/endpoint_test.go @@ -0,0 +1,72 @@ +package identity_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/identity" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/endpoints" + "github.com/stretchr/testify/suite" +) + +type EndpointSuite struct { + suite.Suite + server *httptest.Server +} + +func TestEndpointSuite(t *testing.T) { + suite.Run(t, new(EndpointSuite)) +} + +func (s *EndpointSuite) SetupTest() { + s.server = httptest.NewServer( + identity.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *EndpointSuite) TearDownTest() { + s.server.Close() +} + +func (s *EndpointSuite) TestListEndpoints() { + pages, err := endpoints.List( + testhelper.ServiceClient(s.server.URL), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + listed, err := endpoints.ExtractEndpoints(pages) + s.Require().NoError(err) + + s.Assert().Contains( + identityEndpointIDs(listed), + "compute-public", + ) +} + +func (s *EndpointSuite) TestGetEndpoint() { + result := endpoints.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + "compute-public", + ) + endpoint, err := result.Extract() + s.Require().NoError(err) + s.Require().NotNil(endpoint) + + s.Assert().Equal("compute-public", endpoint.ID) + s.Assert().Equal("compute", endpoint.ServiceID) + s.Assert().Equal(gophercloud.AvailabilityPublic, endpoint.Availability) + s.Assert().Equal(s.server.URL+"/compute/v2.1/demo", endpoint.URL) +} + +func identityEndpointIDs(listed []endpoints.Endpoint) []string { + ids := make([]string, 0, len(listed)) + for _, endpoint := range listed { + ids = append(ids, endpoint.ID) + } + + return ids +} diff --git a/internal/api/identity/router.go b/internal/api/identity/router.go index e2cd4da..de81b5a 100644 --- a/internal/api/identity/router.go +++ b/internal/api/identity/router.go @@ -41,6 +41,8 @@ func (h Handler) Router() http.Handler { router.Get("/roles/{role_id}", h.getRole) router.Get("/services", h.listServices) router.Get("/services/{service_id}", h.getService) + router.Get("/endpoints", h.listEndpoints) + router.Get("/endpoints/{endpoint_id}", h.getEndpoint) return router } diff --git a/internal/app/identity/endpoint.go b/internal/app/identity/endpoint.go new file mode 100644 index 0000000..0db21ab --- /dev/null +++ b/internal/app/identity/endpoint.go @@ -0,0 +1,40 @@ +package identity + +import ( + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/endpoints" +) + +func (s Service) Endpoints(baseURL string) []endpoints.Endpoint { + catalog := s.Catalog(baseURL) + result := []endpoints.Endpoint{} + for _, entry := range catalog { + for _, endpoint := range entry.Endpoints { + result = append(result, endpoints.Endpoint{ + ID: endpoint.ID, + Availability: gophercloud.Availability(endpoint.Interface), + Name: entry.Name, + Region: endpoint.Region, + ServiceID: entry.ID, + URL: endpoint.URL, + Enabled: true, + Description: "SandStack " + entry.Name + " endpoint", + }) + } + } + + return result +} + +func (s Service) EndpointByID( + baseURL string, + id string, +) (endpoints.Endpoint, bool) { + for _, endpoint := range s.Endpoints(baseURL) { + if endpoint.ID == id { + return endpoint, true + } + } + + return endpoints.Endpoint{}, false +} From 979d0d168ce5067fc705cf72ae832eb278331f6d Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:51:03 +0900 Subject: [PATCH 06/11] refactor(identity): add repository-backed principals --- internal/api/identity/router.go | 20 ++++- internal/api/router.go | 13 ++- internal/app/identity/auth.go | 37 +++++--- internal/app/identity/errors.go | 9 ++ internal/app/identity/project.go | 8 +- internal/app/identity/repository.go | 35 ++++++++ internal/app/identity/role.go | 8 +- internal/app/identity/service.go | 29 ++++++- internal/app/identity/types.go | 10 +++ internal/app/identity/user.go | 18 ++-- .../identity/memory_project_repository.go | 84 +++++++++++++++++++ .../store/identity/memory_role_repository.go | 64 ++++++++++++++ .../store/identity/memory_user_repository.go | 83 ++++++++++++++++++ 13 files changed, 381 insertions(+), 37 deletions(-) create mode 100644 internal/app/identity/errors.go create mode 100644 internal/app/identity/repository.go create mode 100644 internal/store/identity/memory_project_repository.go create mode 100644 internal/store/identity/memory_role_repository.go create mode 100644 internal/store/identity/memory_user_repository.go diff --git a/internal/api/identity/router.go b/internal/api/identity/router.go index de81b5a..5e97339 100644 --- a/internal/api/identity/router.go +++ b/internal/api/identity/router.go @@ -6,6 +6,7 @@ import ( appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" "github.com/JSYoo5B/SandStack/internal/platform/config" + storeidentity "github.com/JSYoo5B/SandStack/internal/store/identity" "github.com/go-chi/chi/v5" ) @@ -19,8 +20,25 @@ func NewRouter(cfg config.Config) http.Handler { } func NewHandler(cfg config.Config) Handler { + return NewHandlerWithService( + cfg, + appidentity.NewServiceWithRepositories( + cfg, + appidentity.Repositories{ + Users: storeidentity.NewMemoryUserRepository(), + Projects: storeidentity.NewMemoryProjectRepository(), + Roles: storeidentity.NewMemoryRoleRepository(), + }, + ), + ) +} + +func NewHandlerWithService( + cfg config.Config, + service appidentity.Service, +) Handler { return Handler{ - service: appidentity.NewService(cfg), + service: service, config: cfg, } } diff --git a/internal/api/router.go b/internal/api/router.go index 567e1e2..6b39d08 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -11,6 +11,7 @@ import ( "github.com/JSYoo5B/SandStack/internal/api/placement" "github.com/JSYoo5B/SandStack/internal/api/volume" appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" appimage "github.com/JSYoo5B/SandStack/internal/app/image" appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" "github.com/JSYoo5B/SandStack/internal/app/requestlog" @@ -19,6 +20,7 @@ import ( "github.com/JSYoo5B/SandStack/internal/platform/config" "github.com/JSYoo5B/SandStack/internal/platform/idgen" storecompute "github.com/JSYoo5B/SandStack/internal/store/compute" + storeidentity "github.com/JSYoo5B/SandStack/internal/store/identity" storeimage "github.com/JSYoo5B/SandStack/internal/store/image" storenetwork "github.com/JSYoo5B/SandStack/internal/store/network" storevolume "github.com/JSYoo5B/SandStack/internal/store/volume" @@ -30,7 +32,15 @@ func NewRouter(cfg config.Config) http.Handler { router.Use(requestID) requests := requestlog.NewService() router.Use(recordRequests(requests)) - identityHandler := identity.NewHandler(cfg) + identityService := appidentity.NewServiceWithRepositories( + cfg, + appidentity.Repositories{ + Users: storeidentity.NewMemoryUserRepository(), + Projects: storeidentity.NewMemoryProjectRepository(), + Roles: storeidentity.NewMemoryRoleRepository(), + }, + ) + identityHandler := identity.NewHandlerWithService(cfg, identityService) computeService := appcompute.NewServiceWithRuntime( storecompute.NewMemoryServerRepository(), clock.Wall(), @@ -54,6 +64,7 @@ func NewRouter(cfg config.Config) http.Handler { ) router.Mount("/_sandstack", admin.NewRouterWithState(func() { + identityService.Reset() computeService.Reset() imageService.Reset() networkService.Reset() diff --git a/internal/app/identity/auth.go b/internal/app/identity/auth.go index 00adbda..71e95ed 100644 --- a/internal/app/identity/auth.go +++ b/internal/app/identity/auth.go @@ -5,42 +5,57 @@ import ( "time" "github.com/JSYoo5B/SandStack/internal/platform/idgen" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens" ) var ErrInvalidCredentials = errors.New("invalid identity credentials") func (s Service) AuthenticatePassword(auth PasswordAuth, baseURL string) (IssuedToken, error) { - if auth.Username != s.config.Username || auth.Password != s.config.Password || auth.ProjectName != s.config.ProjectName { + user, err := s.repositories.Users.FindByName(auth.Username) + if err != nil || user.Password != auth.Password { + return IssuedToken{}, ErrInvalidCredentials + } + + project, err := s.repositories.Projects.FindByName(auth.ProjectName) + if err != nil { return IssuedToken{}, ErrInvalidCredentials } now := time.Now().UTC() + roles := s.repositories.Roles.List() return IssuedToken{ ID: "sandstack-" + idgen.RandomHex(16), ExpiresAt: now.Add(24 * time.Hour).Format(time.RFC3339), IssuedAt: now.Format(time.RFC3339), Methods: []string{"password"}, User: tokens.User{ - ID: s.config.UserID, - Name: s.config.Username, + ID: user.ID, + Name: user.Name, Domain: defaultDomain(), }, Project: tokens.Project{ - ID: s.config.ProjectID, - Name: s.config.ProjectName, + ID: project.ID, + Name: project.Name, Domain: defaultDomain(), }, - Roles: []tokens.Role{ - { - ID: s.config.RoleName, - Name: s.config.RoleName, - }, - }, + Roles: tokenRoles(roles), Catalog: s.Catalog(baseURL), }, nil } +func tokenRoles(roles []roles.Role) []tokens.Role { + result := make([]tokens.Role, 0, len(roles)) + for _, role := range roles { + result = append(result, tokens.Role{ + ID: role.ID, + Name: role.Name, + }) + } + + return result +} + func defaultDomain() tokens.Domain { return tokens.Domain{ ID: "default", diff --git a/internal/app/identity/errors.go b/internal/app/identity/errors.go new file mode 100644 index 0000000..d8a7646 --- /dev/null +++ b/internal/app/identity/errors.go @@ -0,0 +1,9 @@ +package identity + +import "errors" + +var ErrUserNotFound = errors.New("user not found") + +var ErrProjectNotFound = errors.New("project not found") + +var ErrRoleNotFound = errors.New("role not found") diff --git a/internal/app/identity/project.go b/internal/app/identity/project.go index be802db..503fabf 100644 --- a/internal/app/identity/project.go +++ b/internal/app/identity/project.go @@ -3,10 +3,10 @@ package identity import "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects" func (s Service) Projects() []projects.Project { - return []projects.Project{s.Project()} + return s.repositories.Projects.List() } -func (s Service) Project() projects.Project { +func (s Service) defaultProject() projects.Project { return projects.Project{ ID: s.config.ProjectID, Name: s.config.ProjectName, @@ -20,8 +20,8 @@ func (s Service) Project() projects.Project { } func (s Service) ProjectByID(id string) (projects.Project, bool) { - project := s.Project() - if project.ID != id { + project, err := s.repositories.Projects.Get(id) + if err != nil { return projects.Project{}, false } diff --git a/internal/app/identity/repository.go b/internal/app/identity/repository.go new file mode 100644 index 0000000..9afd87a --- /dev/null +++ b/internal/app/identity/repository.go @@ -0,0 +1,35 @@ +package identity + +import ( + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" +) + +type Repositories struct { + Users UserRepository + Projects ProjectRepository + Roles RoleRepository +} + +type UserRepository interface { + Save(user User) User + List() []User + Get(id string) (User, error) + FindByName(name string) (User, error) + Reset() +} + +type ProjectRepository interface { + Save(project projects.Project) projects.Project + List() []projects.Project + Get(id string) (projects.Project, error) + FindByName(name string) (projects.Project, error) + Reset() +} + +type RoleRepository interface { + Save(role roles.Role) roles.Role + List() []roles.Role + Get(id string) (roles.Role, error) + Reset() +} diff --git a/internal/app/identity/role.go b/internal/app/identity/role.go index e474dd3..90d4077 100644 --- a/internal/app/identity/role.go +++ b/internal/app/identity/role.go @@ -3,10 +3,10 @@ package identity import "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" func (s Service) Roles() []roles.Role { - return []roles.Role{s.Role()} + return s.repositories.Roles.List() } -func (s Service) Role() roles.Role { +func (s Service) defaultRole() roles.Role { return roles.Role{ DomainID: "default", ID: s.config.RoleName, @@ -19,8 +19,8 @@ func (s Service) Role() roles.Role { } func (s Service) RoleByID(id string) (roles.Role, bool) { - role := s.Role() - if role.ID != id { + role, err := s.repositories.Roles.Get(id) + if err != nil { return roles.Role{}, false } diff --git a/internal/app/identity/service.go b/internal/app/identity/service.go index 27df88d..39e5b00 100644 --- a/internal/app/identity/service.go +++ b/internal/app/identity/service.go @@ -3,9 +3,32 @@ package identity import "github.com/JSYoo5B/SandStack/internal/platform/config" type Service struct { - config config.Config + config config.Config + repositories Repositories } -func NewService(cfg config.Config) Service { - return Service{config: cfg} +func NewServiceWithRepositories( + cfg config.Config, + repositories Repositories, +) Service { + service := Service{ + config: cfg, + repositories: repositories, + } + service.SeedDefaults() + + return service +} + +func (s Service) SeedDefaults() { + s.repositories.Projects.Save(s.defaultProject()) + s.repositories.Users.Save(s.defaultUser()) + s.repositories.Roles.Save(s.defaultRole()) +} + +func (s Service) Reset() { + s.repositories.Projects.Reset() + s.repositories.Users.Reset() + s.repositories.Roles.Reset() + s.SeedDefaults() } diff --git a/internal/app/identity/types.go b/internal/app/identity/types.go index c80cd8a..e1100c4 100644 --- a/internal/app/identity/types.go +++ b/internal/app/identity/types.go @@ -2,6 +2,16 @@ package identity import "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens" +type User struct { + DefaultProjectID string + Description string + DomainID string + Enabled bool + ID string + Name string + Password string +} + type PasswordAuth struct { Username string Password string diff --git a/internal/app/identity/user.go b/internal/app/identity/user.go index a9356cb..c4ff14d 100644 --- a/internal/app/identity/user.go +++ b/internal/app/identity/user.go @@ -1,19 +1,10 @@ package identity -type User struct { - DefaultProjectID string - Description string - DomainID string - Enabled bool - ID string - Name string -} - func (s Service) Users() []User { - return []User{s.User()} + return s.repositories.Users.List() } -func (s Service) User() User { +func (s Service) defaultUser() User { return User{ DefaultProjectID: s.config.ProjectID, Description: "Default SandStack user", @@ -21,12 +12,13 @@ func (s Service) User() User { Enabled: true, ID: s.config.UserID, Name: s.config.Username, + Password: s.config.Password, } } func (s Service) UserByID(id string) (User, bool) { - user := s.User() - if user.ID != id { + user, err := s.repositories.Users.Get(id) + if err != nil { return User{}, false } diff --git a/internal/store/identity/memory_project_repository.go b/internal/store/identity/memory_project_repository.go new file mode 100644 index 0000000..ed0bbbb --- /dev/null +++ b/internal/store/identity/memory_project_repository.go @@ -0,0 +1,84 @@ +package identity + +import ( + "sync" + + appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects" +) + +type MemoryProjectRepository struct { + mu sync.RWMutex + ids []string + projects map[string]projects.Project +} + +func NewMemoryProjectRepository() *MemoryProjectRepository { + return &MemoryProjectRepository{ + projects: map[string]projects.Project{}, + } +} + +func (r *MemoryProjectRepository) Save( + project projects.Project, +) projects.Project { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.projects[project.ID]; !ok { + r.ids = append(r.ids, project.ID) + } + r.projects[project.ID] = project + + return project +} + +func (r *MemoryProjectRepository) List() []projects.Project { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]projects.Project, 0, len(r.ids)) + for _, id := range r.ids { + result = append(result, r.projects[id]) + } + + return result +} + +func (r *MemoryProjectRepository) Get( + id string, +) (projects.Project, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + project, ok := r.projects[id] + if !ok { + return projects.Project{}, appidentity.ErrProjectNotFound + } + + return project, nil +} + +func (r *MemoryProjectRepository) FindByName( + name string, +) (projects.Project, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, id := range r.ids { + project := r.projects[id] + if project.Name == name { + return project, nil + } + } + + return projects.Project{}, appidentity.ErrProjectNotFound +} + +func (r *MemoryProjectRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = nil + r.projects = map[string]projects.Project{} +} diff --git a/internal/store/identity/memory_role_repository.go b/internal/store/identity/memory_role_repository.go new file mode 100644 index 0000000..e38cadb --- /dev/null +++ b/internal/store/identity/memory_role_repository.go @@ -0,0 +1,64 @@ +package identity + +import ( + "sync" + + appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" +) + +type MemoryRoleRepository struct { + mu sync.RWMutex + ids []string + roles map[string]roles.Role +} + +func NewMemoryRoleRepository() *MemoryRoleRepository { + return &MemoryRoleRepository{ + roles: map[string]roles.Role{}, + } +} + +func (r *MemoryRoleRepository) Save(role roles.Role) roles.Role { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.roles[role.ID]; !ok { + r.ids = append(r.ids, role.ID) + } + r.roles[role.ID] = role + + return role +} + +func (r *MemoryRoleRepository) List() []roles.Role { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]roles.Role, 0, len(r.ids)) + for _, id := range r.ids { + result = append(result, r.roles[id]) + } + + return result +} + +func (r *MemoryRoleRepository) Get(id string) (roles.Role, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + role, ok := r.roles[id] + if !ok { + return roles.Role{}, appidentity.ErrRoleNotFound + } + + return role, nil +} + +func (r *MemoryRoleRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = nil + r.roles = map[string]roles.Role{} +} diff --git a/internal/store/identity/memory_user_repository.go b/internal/store/identity/memory_user_repository.go new file mode 100644 index 0000000..523a56c --- /dev/null +++ b/internal/store/identity/memory_user_repository.go @@ -0,0 +1,83 @@ +package identity + +import ( + "sync" + + appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" +) + +type MemoryUserRepository struct { + mu sync.RWMutex + ids []string + users map[string]appidentity.User +} + +func NewMemoryUserRepository() *MemoryUserRepository { + return &MemoryUserRepository{ + users: map[string]appidentity.User{}, + } +} + +func (r *MemoryUserRepository) Save( + user appidentity.User, +) appidentity.User { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.users[user.ID]; !ok { + r.ids = append(r.ids, user.ID) + } + r.users[user.ID] = user + + return user +} + +func (r *MemoryUserRepository) List() []appidentity.User { + r.mu.RLock() + defer r.mu.RUnlock() + + users := make([]appidentity.User, 0, len(r.ids)) + for _, id := range r.ids { + users = append(users, r.users[id]) + } + + return users +} + +func (r *MemoryUserRepository) Get( + id string, +) (appidentity.User, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + user, ok := r.users[id] + if !ok { + return appidentity.User{}, appidentity.ErrUserNotFound + } + + return user, nil +} + +func (r *MemoryUserRepository) FindByName( + name string, +) (appidentity.User, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, id := range r.ids { + user := r.users[id] + if user.Name == name { + return user, nil + } + } + + return appidentity.User{}, appidentity.ErrUserNotFound +} + +func (r *MemoryUserRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = nil + r.users = map[string]appidentity.User{} +} From 2a270e67c14b79af96a21284914d96252b642ba0 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:53:22 +0900 Subject: [PATCH 07/11] feat(identity): persist issued tokens --- internal/api/identity/router.go | 4 ++ internal/api/identity/token.go | 48 +++++++++++++ internal/api/identity/token_test.go | 67 +++++++++++++++++++ internal/api/router.go | 1 + internal/app/identity/auth.go | 14 +++- internal/app/identity/errors.go | 2 + internal/app/identity/repository.go | 8 +++ internal/app/identity/service.go | 1 + .../store/identity/memory_token_repository.go | 62 +++++++++++++++++ 9 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 internal/store/identity/memory_token_repository.go diff --git a/internal/api/identity/router.go b/internal/api/identity/router.go index 5e97339..a560685 100644 --- a/internal/api/identity/router.go +++ b/internal/api/identity/router.go @@ -28,6 +28,7 @@ func NewHandler(cfg config.Config) Handler { Users: storeidentity.NewMemoryUserRepository(), Projects: storeidentity.NewMemoryProjectRepository(), Roles: storeidentity.NewMemoryRoleRepository(), + Tokens: storeidentity.NewMemoryTokenRepository(), }, ), ) @@ -51,6 +52,9 @@ func (h Handler) Router() http.Handler { router := chi.NewRouter() router.Get("/", h.version) router.Post("/auth/tokens", h.createToken) + router.Get("/auth/tokens", h.getToken) + router.Head("/auth/tokens", h.validateToken) + router.Delete("/auth/tokens", h.revokeToken) router.Get("/projects", h.listProjects) router.Get("/projects/{project_id}", h.getProject) router.Get("/users", h.listUsers) diff --git a/internal/api/identity/token.go b/internal/api/identity/token.go index 212f452..cef51fd 100644 --- a/internal/api/identity/token.go +++ b/internal/api/identity/token.go @@ -34,3 +34,51 @@ func (h Handler) createToken(w http.ResponseWriter, r *http.Request) { Token: toIssuedToken(issuedToken), }) } + +func (h Handler) getToken(w http.ResponseWriter, r *http.Request) { + tokenID := r.Header.Get("X-Subject-Token") + issuedToken, err := h.service.TokenByID(tokenID) + if errors.Is(err, appidentity.ErrTokenNotFound) { + respond.Error(w, http.StatusNotFound, "token not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "token lookup failed") + return + } + + w.Header().Set("X-Subject-Token", issuedToken.ID) + respond.JSON(w, http.StatusOK, tokenResponse{ + Token: toIssuedToken(issuedToken), + }) +} + +func (h Handler) validateToken(w http.ResponseWriter, r *http.Request) { + tokenID := r.Header.Get("X-Subject-Token") + issuedToken, err := h.service.TokenByID(tokenID) + if errors.Is(err, appidentity.ErrTokenNotFound) { + w.WriteHeader(http.StatusNotFound) + return + } + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("X-Subject-Token", issuedToken.ID) + w.WriteHeader(http.StatusOK) +} + +func (h Handler) revokeToken(w http.ResponseWriter, r *http.Request) { + err := h.service.RevokeToken(r.Header.Get("X-Subject-Token")) + if errors.Is(err, appidentity.ErrTokenNotFound) { + respond.Error(w, http.StatusNotFound, "token not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "token revoke failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/identity/token_test.go b/internal/api/identity/token_test.go index 4317ea1..74d3943 100644 --- a/internal/api/identity/token_test.go +++ b/internal/api/identity/token_test.go @@ -70,6 +70,73 @@ func (s *TokenSuite) TestPasswordAuth() { ) } +func (s *TokenSuite) TestGetIssuedToken() { + client := testhelper.ServiceClient(s.server.URL) + result := tokens.Create( + s.T().Context(), + client, + testhelper.PasswordAuthOptions(), + ) + tokenID, err := result.ExtractTokenID() + s.Require().NoError(err) + s.Require().NotEmpty(tokenID) + + getResult := tokens.Get( + s.T().Context(), + client, + tokenID, + ) + found, err := getResult.ExtractToken() + s.Require().NoError(err) + s.Require().NotNil(found) + + user, err := getResult.ExtractUser() + s.Require().NoError(err) + s.Require().NotNil(user) + project, err := getResult.ExtractProject() + s.Require().NoError(err) + s.Require().NotNil(project) + + s.Assert().Equal(tokenID, found.ID) + s.Assert().Equal("admin", user.Name) + s.Assert().Equal("demo", project.Name) +} + +func (s *TokenSuite) TestValidateAndRevokeIssuedToken() { + client := testhelper.ServiceClient(s.server.URL) + result := tokens.Create( + s.T().Context(), + client, + testhelper.PasswordAuthOptions(), + ) + tokenID, err := result.ExtractTokenID() + s.Require().NoError(err) + s.Require().NotEmpty(tokenID) + + ok, err := tokens.Validate( + s.T().Context(), + client, + tokenID, + ) + s.Require().NoError(err) + s.Assert().True(ok) + + revokeResult := tokens.Revoke( + s.T().Context(), + client, + tokenID, + ) + s.Require().NoError(revokeResult.Err) + + ok, err = tokens.Validate( + s.T().Context(), + client, + tokenID, + ) + s.Require().NoError(err) + s.Assert().False(ok) +} + func computeEndpoint(catalog []tokens.CatalogEntry) string { for _, entry := range catalog { if entry.Type != "compute" { diff --git a/internal/api/router.go b/internal/api/router.go index 6b39d08..d94817d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -38,6 +38,7 @@ func NewRouter(cfg config.Config) http.Handler { Users: storeidentity.NewMemoryUserRepository(), Projects: storeidentity.NewMemoryProjectRepository(), Roles: storeidentity.NewMemoryRoleRepository(), + Tokens: storeidentity.NewMemoryTokenRepository(), }, ) identityHandler := identity.NewHandlerWithService(cfg, identityService) diff --git a/internal/app/identity/auth.go b/internal/app/identity/auth.go index 71e95ed..4020552 100644 --- a/internal/app/identity/auth.go +++ b/internal/app/identity/auth.go @@ -24,7 +24,7 @@ func (s Service) AuthenticatePassword(auth PasswordAuth, baseURL string) (Issued now := time.Now().UTC() roles := s.repositories.Roles.List() - return IssuedToken{ + issuedToken := IssuedToken{ ID: "sandstack-" + idgen.RandomHex(16), ExpiresAt: now.Add(24 * time.Hour).Format(time.RFC3339), IssuedAt: now.Format(time.RFC3339), @@ -41,7 +41,17 @@ func (s Service) AuthenticatePassword(auth PasswordAuth, baseURL string) (Issued }, Roles: tokenRoles(roles), Catalog: s.Catalog(baseURL), - }, nil + } + + return s.repositories.Tokens.Save(issuedToken), nil +} + +func (s Service) TokenByID(id string) (IssuedToken, error) { + return s.repositories.Tokens.Get(id) +} + +func (s Service) RevokeToken(id string) error { + return s.repositories.Tokens.Delete(id) } func tokenRoles(roles []roles.Role) []tokens.Role { diff --git a/internal/app/identity/errors.go b/internal/app/identity/errors.go index d8a7646..b95085d 100644 --- a/internal/app/identity/errors.go +++ b/internal/app/identity/errors.go @@ -7,3 +7,5 @@ var ErrUserNotFound = errors.New("user not found") var ErrProjectNotFound = errors.New("project not found") var ErrRoleNotFound = errors.New("role not found") + +var ErrTokenNotFound = errors.New("token not found") diff --git a/internal/app/identity/repository.go b/internal/app/identity/repository.go index 9afd87a..cd6662b 100644 --- a/internal/app/identity/repository.go +++ b/internal/app/identity/repository.go @@ -9,6 +9,7 @@ type Repositories struct { Users UserRepository Projects ProjectRepository Roles RoleRepository + Tokens TokenRepository } type UserRepository interface { @@ -33,3 +34,10 @@ type RoleRepository interface { Get(id string) (roles.Role, error) Reset() } + +type TokenRepository interface { + Save(token IssuedToken) IssuedToken + Get(id string) (IssuedToken, error) + Delete(id string) error + Reset() +} diff --git a/internal/app/identity/service.go b/internal/app/identity/service.go index 39e5b00..3ecd7f6 100644 --- a/internal/app/identity/service.go +++ b/internal/app/identity/service.go @@ -30,5 +30,6 @@ func (s Service) Reset() { s.repositories.Projects.Reset() s.repositories.Users.Reset() s.repositories.Roles.Reset() + s.repositories.Tokens.Reset() s.SeedDefaults() } diff --git a/internal/store/identity/memory_token_repository.go b/internal/store/identity/memory_token_repository.go new file mode 100644 index 0000000..8d2e692 --- /dev/null +++ b/internal/store/identity/memory_token_repository.go @@ -0,0 +1,62 @@ +package identity + +import ( + "sync" + + appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" +) + +type MemoryTokenRepository struct { + mu sync.RWMutex + tokens map[string]appidentity.IssuedToken +} + +func NewMemoryTokenRepository() *MemoryTokenRepository { + return &MemoryTokenRepository{ + tokens: map[string]appidentity.IssuedToken{}, + } +} + +func (r *MemoryTokenRepository) Save( + token appidentity.IssuedToken, +) appidentity.IssuedToken { + r.mu.Lock() + defer r.mu.Unlock() + + r.tokens[token.ID] = token + + return token +} + +func (r *MemoryTokenRepository) Get( + id string, +) (appidentity.IssuedToken, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + token, ok := r.tokens[id] + if !ok { + return appidentity.IssuedToken{}, appidentity.ErrTokenNotFound + } + + return token, nil +} + +func (r *MemoryTokenRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.tokens[id]; !ok { + return appidentity.ErrTokenNotFound + } + delete(r.tokens, id) + + return nil +} + +func (r *MemoryTokenRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.tokens = map[string]appidentity.IssuedToken{} +} From 94e347f41c148c6bd234dad9fa3487253e319cf4 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:55:16 +0900 Subject: [PATCH 08/11] refactor(identity): persist service catalog state --- internal/api/identity/router.go | 10 +- internal/api/router.go | 10 +- internal/app/identity/catalog.go | 108 +++++++++++++----- internal/app/identity/endpoint.go | 51 ++++++--- internal/app/identity/errors.go | 4 + internal/app/identity/repository.go | 25 +++- internal/app/identity/service.go | 8 ++ internal/app/identity/service_catalog.go | 27 +++-- internal/app/identity/types.go | 18 +++ .../identity/memory_endpoint_repository.go | 84 ++++++++++++++ .../identity/memory_service_repository.go | 67 +++++++++++ 11 files changed, 340 insertions(+), 72 deletions(-) create mode 100644 internal/store/identity/memory_endpoint_repository.go create mode 100644 internal/store/identity/memory_service_repository.go diff --git a/internal/api/identity/router.go b/internal/api/identity/router.go index a560685..f66b592 100644 --- a/internal/api/identity/router.go +++ b/internal/api/identity/router.go @@ -25,10 +25,12 @@ func NewHandler(cfg config.Config) Handler { appidentity.NewServiceWithRepositories( cfg, appidentity.Repositories{ - Users: storeidentity.NewMemoryUserRepository(), - Projects: storeidentity.NewMemoryProjectRepository(), - Roles: storeidentity.NewMemoryRoleRepository(), - Tokens: storeidentity.NewMemoryTokenRepository(), + Users: storeidentity.NewMemoryUserRepository(), + Projects: storeidentity.NewMemoryProjectRepository(), + Roles: storeidentity.NewMemoryRoleRepository(), + Tokens: storeidentity.NewMemoryTokenRepository(), + Services: storeidentity.NewMemoryServiceRepository(), + Endpoints: storeidentity.NewMemoryEndpointRepository(), }, ), ) diff --git a/internal/api/router.go b/internal/api/router.go index d94817d..0a45b10 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -35,10 +35,12 @@ func NewRouter(cfg config.Config) http.Handler { identityService := appidentity.NewServiceWithRepositories( cfg, appidentity.Repositories{ - Users: storeidentity.NewMemoryUserRepository(), - Projects: storeidentity.NewMemoryProjectRepository(), - Roles: storeidentity.NewMemoryRoleRepository(), - Tokens: storeidentity.NewMemoryTokenRepository(), + Users: storeidentity.NewMemoryUserRepository(), + Projects: storeidentity.NewMemoryProjectRepository(), + Roles: storeidentity.NewMemoryRoleRepository(), + Tokens: storeidentity.NewMemoryTokenRepository(), + Services: storeidentity.NewMemoryServiceRepository(), + Endpoints: storeidentity.NewMemoryEndpointRepository(), }, ) identityHandler := identity.NewHandlerWithService(cfg, identityService) diff --git a/internal/app/identity/catalog.go b/internal/app/identity/catalog.go index dc029db..7ae2a1c 100644 --- a/internal/app/identity/catalog.go +++ b/internal/app/identity/catalog.go @@ -9,13 +9,64 @@ type catalogService struct { } func (s Service) Catalog(baseURL string) []tokens.CatalogEntry { - projectID := s.config.ProjectID + services := s.repositories.Services.List() + catalog := make([]tokens.CatalogEntry, 0, len(services)) + for _, service := range services { + catalog = append(catalog, s.catalogEntry(baseURL, service)) + } + + return catalog +} - catalog := []tokens.CatalogEntry{ - s.service("identity", "identity", baseURL+"/identity/v3"), +func (s Service) catalogEntry( + baseURL string, + service ServiceDefinition, +) tokens.CatalogEntry { + endpoints := s.repositories.Endpoints.ListByServiceID(service.ID) + tokenEndpoints := make([]tokens.Endpoint, 0, len(endpoints)) + for _, endpoint := range endpoints { + tokenEndpoints = append(tokenEndpoints, tokens.Endpoint{ + ID: endpoint.ID, + Interface: endpoint.Interface, + Region: endpoint.Region, + RegionID: endpoint.Region, + URL: baseURL + endpoint.Path, + }) } - optionalServices := []catalogService{ + return tokens.CatalogEntry{ + ID: service.ID, + Name: service.Name, + Type: service.Type, + Endpoints: tokenEndpoints, + } +} + +func (s Service) defaultServices() []ServiceDefinition { + return []ServiceDefinition{ + s.defaultService("identity", "identity"), + s.defaultService("compute", "compute"), + s.defaultService("network", "network"), + s.defaultService("image", "image"), + s.defaultService("volumev3", "volumev3"), + s.defaultService("placement", "placement"), + } +} + +func (s Service) defaultService(id string, serviceType string) ServiceDefinition { + return ServiceDefinition{ + ID: id, + Name: serviceType, + Type: serviceType, + Description: "SandStack " + serviceType + " service", + Enabled: true, + } +} + +func (s Service) defaultEndpoints() []EndpointDefinition { + projectID := s.config.ProjectID + services := []catalogService{ + {id: "identity", serviceType: "identity", path: "/identity/v3"}, {id: "compute", serviceType: "compute", path: "/compute/v2.1/" + projectID}, {id: "network", serviceType: "network", path: "/network/v2.0"}, {id: "image", serviceType: "image", path: "/image/v2"}, @@ -23,36 +74,31 @@ func (s Service) Catalog(baseURL string) []tokens.CatalogEntry { {id: "placement", serviceType: "placement", path: "/placement"}, } - for _, service := range optionalServices { - catalog = append(catalog, s.service( - service.id, - service.serviceType, - baseURL+service.path, - )) + endpoints := []EndpointDefinition{} + for _, service := range services { + endpoints = append( + endpoints, + s.defaultEndpoint(service.id, "public", service.path), + s.defaultEndpoint(service.id, "internal", service.path), + s.defaultEndpoint(service.id, "admin", service.path), + ) } - return catalog -} - -func (s Service) service(id, serviceType, url string) tokens.CatalogEntry { - return tokens.CatalogEntry{ - ID: id, - Name: serviceType, - Type: serviceType, - Endpoints: []tokens.Endpoint{ - s.endpoint(id+"-public", "public", url), - s.endpoint(id+"-internal", "internal", url), - s.endpoint(id+"-admin", "admin", url), - }, - } + return endpoints } -func (s Service) endpoint(id, iface, url string) tokens.Endpoint { - return tokens.Endpoint{ - ID: id, - Interface: iface, - Region: s.config.Region, - RegionID: s.config.Region, - URL: url, +func (s Service) defaultEndpoint( + serviceID string, + iface string, + path string, +) EndpointDefinition { + return EndpointDefinition{ + ID: serviceID + "-" + iface, + ServiceID: serviceID, + Interface: iface, + Region: s.config.Region, + Path: path, + Enabled: true, + Description: "SandStack " + serviceID + " endpoint", } } diff --git a/internal/app/identity/endpoint.go b/internal/app/identity/endpoint.go index 0db21ab..d4541e3 100644 --- a/internal/app/identity/endpoint.go +++ b/internal/app/identity/endpoint.go @@ -6,21 +6,14 @@ import ( ) func (s Service) Endpoints(baseURL string) []endpoints.Endpoint { - catalog := s.Catalog(baseURL) - result := []endpoints.Endpoint{} - for _, entry := range catalog { - for _, endpoint := range entry.Endpoints { - result = append(result, endpoints.Endpoint{ - ID: endpoint.ID, - Availability: gophercloud.Availability(endpoint.Interface), - Name: entry.Name, - Region: endpoint.Region, - ServiceID: entry.ID, - URL: endpoint.URL, - Enabled: true, - Description: "SandStack " + entry.Name + " endpoint", - }) + definitions := s.repositories.Endpoints.List() + result := make([]endpoints.Endpoint, 0, len(definitions)) + for _, endpoint := range definitions { + service, err := s.repositories.Services.Get(endpoint.ServiceID) + if err != nil { + continue } + result = append(result, toEndpoint(baseURL, service, endpoint)) } return result @@ -30,11 +23,31 @@ func (s Service) EndpointByID( baseURL string, id string, ) (endpoints.Endpoint, bool) { - for _, endpoint := range s.Endpoints(baseURL) { - if endpoint.ID == id { - return endpoint, true - } + endpoint, err := s.repositories.Endpoints.Get(id) + if err != nil { + return endpoints.Endpoint{}, false + } + service, err := s.repositories.Services.Get(endpoint.ServiceID) + if err != nil { + return endpoints.Endpoint{}, false } - return endpoints.Endpoint{}, false + return toEndpoint(baseURL, service, endpoint), true +} + +func toEndpoint( + baseURL string, + service ServiceDefinition, + endpoint EndpointDefinition, +) endpoints.Endpoint { + return endpoints.Endpoint{ + ID: endpoint.ID, + Availability: gophercloud.Availability(endpoint.Interface), + Name: service.Name, + Region: endpoint.Region, + ServiceID: endpoint.ServiceID, + URL: baseURL + endpoint.Path, + Enabled: endpoint.Enabled, + Description: endpoint.Description, + } } diff --git a/internal/app/identity/errors.go b/internal/app/identity/errors.go index b95085d..372bc10 100644 --- a/internal/app/identity/errors.go +++ b/internal/app/identity/errors.go @@ -9,3 +9,7 @@ var ErrProjectNotFound = errors.New("project not found") var ErrRoleNotFound = errors.New("role not found") var ErrTokenNotFound = errors.New("token not found") + +var ErrServiceNotFound = errors.New("service not found") + +var ErrEndpointNotFound = errors.New("endpoint not found") diff --git a/internal/app/identity/repository.go b/internal/app/identity/repository.go index cd6662b..0a42bfb 100644 --- a/internal/app/identity/repository.go +++ b/internal/app/identity/repository.go @@ -6,10 +6,12 @@ import ( ) type Repositories struct { - Users UserRepository - Projects ProjectRepository - Roles RoleRepository - Tokens TokenRepository + Users UserRepository + Projects ProjectRepository + Roles RoleRepository + Tokens TokenRepository + Services ServiceRepository + Endpoints EndpointRepository } type UserRepository interface { @@ -41,3 +43,18 @@ type TokenRepository interface { Delete(id string) error Reset() } + +type ServiceRepository interface { + Save(service ServiceDefinition) ServiceDefinition + List() []ServiceDefinition + Get(id string) (ServiceDefinition, error) + Reset() +} + +type EndpointRepository interface { + Save(endpoint EndpointDefinition) EndpointDefinition + List() []EndpointDefinition + Get(id string) (EndpointDefinition, error) + ListByServiceID(serviceID string) []EndpointDefinition + Reset() +} diff --git a/internal/app/identity/service.go b/internal/app/identity/service.go index 3ecd7f6..38cd2e7 100644 --- a/internal/app/identity/service.go +++ b/internal/app/identity/service.go @@ -24,6 +24,12 @@ func (s Service) SeedDefaults() { s.repositories.Projects.Save(s.defaultProject()) s.repositories.Users.Save(s.defaultUser()) s.repositories.Roles.Save(s.defaultRole()) + for _, service := range s.defaultServices() { + s.repositories.Services.Save(service) + } + for _, endpoint := range s.defaultEndpoints() { + s.repositories.Endpoints.Save(endpoint) + } } func (s Service) Reset() { @@ -31,5 +37,7 @@ func (s Service) Reset() { s.repositories.Users.Reset() s.repositories.Roles.Reset() s.repositories.Tokens.Reset() + s.repositories.Services.Reset() + s.repositories.Endpoints.Reset() s.SeedDefaults() } diff --git a/internal/app/identity/service_catalog.go b/internal/app/identity/service_catalog.go index b56e21f..63120fa 100644 --- a/internal/app/identity/service_catalog.go +++ b/internal/app/identity/service_catalog.go @@ -3,15 +3,15 @@ package identity import "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/services" func (s Service) Services(baseURL string) []services.Service { - catalog := s.Catalog(baseURL) - result := make([]services.Service, 0, len(catalog)) - for _, entry := range catalog { + definitions := s.repositories.Services.List() + result := make([]services.Service, 0, len(definitions)) + for _, entry := range definitions { result = append(result, services.Service{ ID: entry.ID, Name: entry.Name, - Description: "SandStack " + entry.Name + " service", + Description: entry.Description, Type: entry.Type, - Enabled: true, + Enabled: entry.Enabled, Links: map[string]any{}, Extra: map[string]any{}, }) @@ -21,11 +21,18 @@ func (s Service) Services(baseURL string) []services.Service { } func (s Service) ServiceByID(baseURL, id string) (services.Service, bool) { - for _, service := range s.Services(baseURL) { - if service.ID == id { - return service, true - } + service, err := s.repositories.Services.Get(id) + if err != nil { + return services.Service{}, false } - return services.Service{}, false + return services.Service{ + ID: service.ID, + Name: service.Name, + Description: service.Description, + Type: service.Type, + Enabled: service.Enabled, + Links: map[string]any{}, + Extra: map[string]any{}, + }, true } diff --git a/internal/app/identity/types.go b/internal/app/identity/types.go index e1100c4..5601485 100644 --- a/internal/app/identity/types.go +++ b/internal/app/identity/types.go @@ -12,6 +12,24 @@ type User struct { Password string } +type ServiceDefinition struct { + ID string + Name string + Type string + Description string + Enabled bool +} + +type EndpointDefinition struct { + ID string + ServiceID string + Interface string + Region string + Path string + Enabled bool + Description string +} + type PasswordAuth struct { Username string Password string diff --git a/internal/store/identity/memory_endpoint_repository.go b/internal/store/identity/memory_endpoint_repository.go new file mode 100644 index 0000000..5030bf4 --- /dev/null +++ b/internal/store/identity/memory_endpoint_repository.go @@ -0,0 +1,84 @@ +package identity + +import ( + "sync" + + appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" +) + +type MemoryEndpointRepository struct { + mu sync.RWMutex + ids []string + endpoints map[string]appidentity.EndpointDefinition +} + +func NewMemoryEndpointRepository() *MemoryEndpointRepository { + return &MemoryEndpointRepository{ + endpoints: map[string]appidentity.EndpointDefinition{}, + } +} + +func (r *MemoryEndpointRepository) Save( + endpoint appidentity.EndpointDefinition, +) appidentity.EndpointDefinition { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.endpoints[endpoint.ID]; !ok { + r.ids = append(r.ids, endpoint.ID) + } + r.endpoints[endpoint.ID] = endpoint + + return endpoint +} + +func (r *MemoryEndpointRepository) List() []appidentity.EndpointDefinition { + r.mu.RLock() + defer r.mu.RUnlock() + + endpoints := make([]appidentity.EndpointDefinition, 0, len(r.ids)) + for _, id := range r.ids { + endpoints = append(endpoints, r.endpoints[id]) + } + + return endpoints +} + +func (r *MemoryEndpointRepository) Get( + id string, +) (appidentity.EndpointDefinition, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + endpoint, ok := r.endpoints[id] + if !ok { + return appidentity.EndpointDefinition{}, appidentity.ErrEndpointNotFound + } + + return endpoint, nil +} + +func (r *MemoryEndpointRepository) ListByServiceID( + serviceID string, +) []appidentity.EndpointDefinition { + r.mu.RLock() + defer r.mu.RUnlock() + + endpoints := []appidentity.EndpointDefinition{} + for _, id := range r.ids { + endpoint := r.endpoints[id] + if endpoint.ServiceID == serviceID { + endpoints = append(endpoints, endpoint) + } + } + + return endpoints +} + +func (r *MemoryEndpointRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = nil + r.endpoints = map[string]appidentity.EndpointDefinition{} +} diff --git a/internal/store/identity/memory_service_repository.go b/internal/store/identity/memory_service_repository.go new file mode 100644 index 0000000..f1c5e09 --- /dev/null +++ b/internal/store/identity/memory_service_repository.go @@ -0,0 +1,67 @@ +package identity + +import ( + "sync" + + appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" +) + +type MemoryServiceRepository struct { + mu sync.RWMutex + ids []string + services map[string]appidentity.ServiceDefinition +} + +func NewMemoryServiceRepository() *MemoryServiceRepository { + return &MemoryServiceRepository{ + services: map[string]appidentity.ServiceDefinition{}, + } +} + +func (r *MemoryServiceRepository) Save( + service appidentity.ServiceDefinition, +) appidentity.ServiceDefinition { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.services[service.ID]; !ok { + r.ids = append(r.ids, service.ID) + } + r.services[service.ID] = service + + return service +} + +func (r *MemoryServiceRepository) List() []appidentity.ServiceDefinition { + r.mu.RLock() + defer r.mu.RUnlock() + + services := make([]appidentity.ServiceDefinition, 0, len(r.ids)) + for _, id := range r.ids { + services = append(services, r.services[id]) + } + + return services +} + +func (r *MemoryServiceRepository) Get( + id string, +) (appidentity.ServiceDefinition, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + service, ok := r.services[id] + if !ok { + return appidentity.ServiceDefinition{}, appidentity.ErrServiceNotFound + } + + return service, nil +} + +func (r *MemoryServiceRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = nil + r.services = map[string]appidentity.ServiceDefinition{} +} From 7ba8168a48c6cb04f74d5493a59046ba5cb7583a Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:57:24 +0900 Subject: [PATCH 09/11] feat(identity): add sqlite principal repositories --- .../identity/sqlite_principal_repositories.go | 464 ++++++++++++++++++ .../sqlite_principal_repositories_test.go | 134 +++++ 2 files changed, 598 insertions(+) create mode 100644 internal/store/identity/sqlite_principal_repositories.go create mode 100644 internal/store/identity/sqlite_principal_repositories_test.go diff --git a/internal/store/identity/sqlite_principal_repositories.go b/internal/store/identity/sqlite_principal_repositories.go new file mode 100644 index 0000000..663ad04 --- /dev/null +++ b/internal/store/identity/sqlite_principal_repositories.go @@ -0,0 +1,464 @@ +package identity + +import ( + "database/sql" + "errors" + "fmt" + + appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" + _ "modernc.org/sqlite" +) + +type SQLitePrincipalRepositories struct { + db *sql.DB + Users *SQLiteUserRepository + Projects *SQLiteProjectRepository + Roles *SQLiteRoleRepository +} + +func OpenSQLitePrincipalRepositories( + dataSourceName string, +) (*SQLitePrincipalRepositories, error) { + db, err := sql.Open("sqlite", dataSourceName) + if err != nil { + return nil, err + } + + repositories := &SQLitePrincipalRepositories{ + db: db, + Users: &SQLiteUserRepository{db: db}, + Projects: &SQLiteProjectRepository{db: db}, + Roles: &SQLiteRoleRepository{db: db}, + } + if err := repositories.Init(); err != nil { + _ = db.Close() + return nil, err + } + + return repositories, nil +} + +func (r *SQLitePrincipalRepositories) Init() error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS identity_users ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + default_project_id TEXT NOT NULL, + description TEXT NOT NULL, + domain_id TEXT NOT NULL, + enabled INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS identity_projects ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL, + domain_id TEXT NOT NULL, + enabled INTEGER NOT NULL, + is_domain INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS identity_roles ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL, + domain_id TEXT NOT NULL + )`, + } + + for _, statement := range statements { + if _, err := r.db.Exec(statement); err != nil { + return err + } + } + + return nil +} + +func (r *SQLitePrincipalRepositories) Close() error { + return r.db.Close() +} + +type SQLiteUserRepository struct { + db *sql.DB +} + +func (r *SQLiteUserRepository) Save(user appidentity.User) appidentity.User { + _, err := r.db.Exec( + `INSERT INTO identity_users ( + id, + name, + password, + default_project_id, + description, + domain_id, + enabled + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + password = excluded.password, + default_project_id = excluded.default_project_id, + description = excluded.description, + domain_id = excluded.domain_id, + enabled = excluded.enabled`, + user.ID, + user.Name, + user.Password, + user.DefaultProjectID, + user.Description, + user.DomainID, + boolToInt(user.Enabled), + ) + if err != nil { + panic(fmt.Errorf("save identity user: %w", err)) + } + + return user +} + +func (r *SQLiteUserRepository) List() []appidentity.User { + rows, err := r.db.Query(` +SELECT + id, + name, + password, + default_project_id, + description, + domain_id, + enabled +FROM identity_users +ORDER BY sequence`) + if err != nil { + panic(fmt.Errorf("list identity users: %w", err)) + } + defer rows.Close() + + users := []appidentity.User{} + for rows.Next() { + user, err := scanUser(rows) + if err != nil { + panic(fmt.Errorf("scan identity user: %w", err)) + } + users = append(users, user) + } + if err := rows.Err(); err != nil { + panic(fmt.Errorf("iterate identity users: %w", err)) + } + + return users +} + +func (r *SQLiteUserRepository) Get(id string) (appidentity.User, error) { + return r.getBy(`id = ?`, id) +} + +func (r *SQLiteUserRepository) FindByName( + name string, +) (appidentity.User, error) { + return r.getBy(`name = ?`, name) +} + +func (r *SQLiteUserRepository) getBy( + condition string, + value string, +) (appidentity.User, error) { + row := r.db.QueryRow(` +SELECT + id, + name, + password, + default_project_id, + description, + domain_id, + enabled +FROM identity_users +WHERE `+condition, value) + + user, err := scanUser(row) + if errors.Is(err, sql.ErrNoRows) { + return appidentity.User{}, appidentity.ErrUserNotFound + } + if err != nil { + return appidentity.User{}, err + } + + return user, nil +} + +func (r *SQLiteUserRepository) Reset() { + if _, err := r.db.Exec(`DELETE FROM identity_users`); err != nil { + panic(fmt.Errorf("reset identity users: %w", err)) + } +} + +type SQLiteProjectRepository struct { + db *sql.DB +} + +func (r *SQLiteProjectRepository) Save(project projects.Project) projects.Project { + _, err := r.db.Exec( + `INSERT INTO identity_projects ( + id, + name, + description, + domain_id, + enabled, + is_domain + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + domain_id = excluded.domain_id, + enabled = excluded.enabled, + is_domain = excluded.is_domain`, + project.ID, + project.Name, + project.Description, + project.DomainID, + boolToInt(project.Enabled), + boolToInt(project.IsDomain), + ) + if err != nil { + panic(fmt.Errorf("save identity project: %w", err)) + } + + return project +} + +func (r *SQLiteProjectRepository) List() []projects.Project { + rows, err := r.db.Query(` +SELECT + id, + name, + description, + domain_id, + enabled, + is_domain +FROM identity_projects +ORDER BY sequence`) + if err != nil { + panic(fmt.Errorf("list identity projects: %w", err)) + } + defer rows.Close() + + result := []projects.Project{} + for rows.Next() { + project, err := scanProject(rows) + if err != nil { + panic(fmt.Errorf("scan identity project: %w", err)) + } + result = append(result, project) + } + if err := rows.Err(); err != nil { + panic(fmt.Errorf("iterate identity projects: %w", err)) + } + + return result +} + +func (r *SQLiteProjectRepository) Get(id string) (projects.Project, error) { + return r.getBy(`id = ?`, id) +} + +func (r *SQLiteProjectRepository) FindByName( + name string, +) (projects.Project, error) { + return r.getBy(`name = ?`, name) +} + +func (r *SQLiteProjectRepository) getBy( + condition string, + value string, +) (projects.Project, error) { + row := r.db.QueryRow(` +SELECT + id, + name, + description, + domain_id, + enabled, + is_domain +FROM identity_projects +WHERE `+condition, value) + + project, err := scanProject(row) + if errors.Is(err, sql.ErrNoRows) { + return projects.Project{}, appidentity.ErrProjectNotFound + } + if err != nil { + return projects.Project{}, err + } + + return project, nil +} + +func (r *SQLiteProjectRepository) Reset() { + if _, err := r.db.Exec(`DELETE FROM identity_projects`); err != nil { + panic(fmt.Errorf("reset identity projects: %w", err)) + } +} + +type SQLiteRoleRepository struct { + db *sql.DB +} + +func (r *SQLiteRoleRepository) Save(role roles.Role) roles.Role { + _, err := r.db.Exec( + `INSERT INTO identity_roles ( + id, + name, + description, + domain_id + ) VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + domain_id = excluded.domain_id`, + role.ID, + role.Name, + role.Description, + role.DomainID, + ) + if err != nil { + panic(fmt.Errorf("save identity role: %w", err)) + } + + return role +} + +func (r *SQLiteRoleRepository) List() []roles.Role { + rows, err := r.db.Query(` +SELECT + id, + name, + description, + domain_id +FROM identity_roles +ORDER BY sequence`) + if err != nil { + panic(fmt.Errorf("list identity roles: %w", err)) + } + defer rows.Close() + + result := []roles.Role{} + for rows.Next() { + role, err := scanRole(rows) + if err != nil { + panic(fmt.Errorf("scan identity role: %w", err)) + } + result = append(result, role) + } + if err := rows.Err(); err != nil { + panic(fmt.Errorf("iterate identity roles: %w", err)) + } + + return result +} + +func (r *SQLiteRoleRepository) Get(id string) (roles.Role, error) { + row := r.db.QueryRow(` +SELECT + id, + name, + description, + domain_id +FROM identity_roles +WHERE id = ?`, id) + + role, err := scanRole(row) + if errors.Is(err, sql.ErrNoRows) { + return roles.Role{}, appidentity.ErrRoleNotFound + } + if err != nil { + return roles.Role{}, err + } + + return role, nil +} + +func (r *SQLiteRoleRepository) Reset() { + if _, err := r.db.Exec(`DELETE FROM identity_roles`); err != nil { + panic(fmt.Errorf("reset identity roles: %w", err)) + } +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanUser(scanner rowScanner) (appidentity.User, error) { + var user appidentity.User + var enabled int + err := scanner.Scan( + &user.ID, + &user.Name, + &user.Password, + &user.DefaultProjectID, + &user.Description, + &user.DomainID, + &enabled, + ) + if err != nil { + return appidentity.User{}, err + } + user.Enabled = intToBool(enabled) + + return user, nil +} + +func scanProject(scanner rowScanner) (projects.Project, error) { + var project projects.Project + var enabled int + var isDomain int + err := scanner.Scan( + &project.ID, + &project.Name, + &project.Description, + &project.DomainID, + &enabled, + &isDomain, + ) + if err != nil { + return projects.Project{}, err + } + project.Enabled = intToBool(enabled) + project.IsDomain = intToBool(isDomain) + project.Tags = []string{} + project.Extra = map[string]any{} + + return project, nil +} + +func scanRole(scanner rowScanner) (roles.Role, error) { + var role roles.Role + err := scanner.Scan( + &role.ID, + &role.Name, + &role.Description, + &role.DomainID, + ) + if err != nil { + return roles.Role{}, err + } + role.Links = map[string]any{} + role.Extra = map[string]any{} + role.Options = map[roles.Option]any{} + + return role, nil +} + +func boolToInt(value bool) int { + if value { + return 1 + } + + return 0 +} + +func intToBool(value int) bool { + return value != 0 +} diff --git a/internal/store/identity/sqlite_principal_repositories_test.go b/internal/store/identity/sqlite_principal_repositories_test.go new file mode 100644 index 0000000..1d08b87 --- /dev/null +++ b/internal/store/identity/sqlite_principal_repositories_test.go @@ -0,0 +1,134 @@ +package identity_test + +import ( + "path/filepath" + "testing" + + appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" + storeidentity "github.com/JSYoo5B/SandStack/internal/store/identity" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" + "github.com/stretchr/testify/suite" +) + +type SQLitePrincipalRepositoriesSuite struct { + suite.Suite + repositories *storeidentity.SQLitePrincipalRepositories +} + +func TestSQLitePrincipalRepositoriesSuite(t *testing.T) { + suite.Run(t, new(SQLitePrincipalRepositoriesSuite)) +} + +func (s *SQLitePrincipalRepositoriesSuite) SetupTest() { + repositories, err := storeidentity.OpenSQLitePrincipalRepositories(":memory:") + s.Require().NoError(err) + + s.repositories = repositories +} + +func (s *SQLitePrincipalRepositoriesSuite) TearDownTest() { + s.Require().NoError(s.repositories.Close()) +} + +func (s *SQLitePrincipalRepositoriesSuite) TestCreateListAndFindUser() { + created := s.repositories.Users.Save(appidentity.User{ + ID: "user-1", + Name: "admin", + Password: "password", + DefaultProjectID: "demo", + Description: "Default user", + DomainID: "default", + Enabled: true, + }) + + listed := s.repositories.Users.List() + found, err := s.repositories.Users.FindByName("admin") + s.Require().NoError(err) + + s.Assert().Len(listed, 1) + s.Assert().Equal(created, listed[0]) + s.Assert().Equal(created, found) +} + +func (s *SQLitePrincipalRepositoriesSuite) TestCreateListAndFindProject() { + created := s.repositories.Projects.Save(projects.Project{ + ID: "demo", + Name: "demo", + Description: "Default project", + DomainID: "default", + Enabled: true, + Tags: []string{}, + Extra: map[string]any{}, + }) + + listed := s.repositories.Projects.List() + found, err := s.repositories.Projects.FindByName("demo") + s.Require().NoError(err) + + s.Assert().Len(listed, 1) + s.Assert().Equal(created, listed[0]) + s.Assert().Equal(created, found) +} + +func (s *SQLitePrincipalRepositoriesSuite) TestCreateListAndGetRole() { + created := s.repositories.Roles.Save(roles.Role{ + ID: "admin", + Name: "admin", + Description: "Default role", + DomainID: "default", + Links: map[string]any{}, + Extra: map[string]any{}, + Options: map[roles.Option]any{}, + }) + + listed := s.repositories.Roles.List() + found, err := s.repositories.Roles.Get("admin") + s.Require().NoError(err) + + s.Assert().Len(listed, 1) + s.Assert().Equal(created, listed[0]) + s.Assert().Equal(created, found) +} + +func (s *SQLitePrincipalRepositoriesSuite) TestResetClearsPrincipals() { + s.repositories.Users.Save(appidentity.User{ + ID: "user-1", + Name: "admin", + Password: "password", + DefaultProjectID: "demo", + Description: "Default user", + DomainID: "default", + Enabled: true, + }) + + s.repositories.Users.Reset() + + s.Assert().Empty(s.repositories.Users.List()) +} + +func (s *SQLitePrincipalRepositoriesSuite) TestFileBackedDatabasePersistsPrincipals() { + path := filepath.Join(s.T().TempDir(), "sandstack.db") + repositories, err := storeidentity.OpenSQLitePrincipalRepositories(path) + s.Require().NoError(err) + + created := repositories.Users.Save(appidentity.User{ + ID: "user-1", + Name: "admin", + Password: "password", + DefaultProjectID: "demo", + Description: "Default user", + DomainID: "default", + Enabled: true, + }) + s.Require().NoError(repositories.Close()) + + reopened, err := storeidentity.OpenSQLitePrincipalRepositories(path) + s.Require().NoError(err) + defer reopened.Close() + + found, err := reopened.Users.Get(created.ID) + s.Require().NoError(err) + + s.Assert().Equal(created, found) +} From e55cc79ef8565b9b6f33ad66c430cad67147bde6 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:58:27 +0900 Subject: [PATCH 10/11] feat(identity): add sqlite token repository --- .../store/identity/sqlite_token_repository.go | 128 ++++++++++++++++++ .../identity/sqlite_token_repository_test.go | 104 ++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 internal/store/identity/sqlite_token_repository.go create mode 100644 internal/store/identity/sqlite_token_repository_test.go diff --git a/internal/store/identity/sqlite_token_repository.go b/internal/store/identity/sqlite_token_repository.go new file mode 100644 index 0000000..b194378 --- /dev/null +++ b/internal/store/identity/sqlite_token_repository.go @@ -0,0 +1,128 @@ +package identity + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + + appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" + _ "modernc.org/sqlite" +) + +type SQLiteTokenRepository struct { + db *sql.DB +} + +func OpenSQLiteTokenRepository( + dataSourceName string, +) (*SQLiteTokenRepository, error) { + db, err := sql.Open("sqlite", dataSourceName) + if err != nil { + return nil, err + } + + repository := &SQLiteTokenRepository{db: db} + if err := repository.Init(); err != nil { + _ = db.Close() + return nil, err + } + + return repository, nil +} + +func (r *SQLiteTokenRepository) Init() error { + _, err := r.db.Exec(` +CREATE TABLE IF NOT EXISTS identity_tokens ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + token_json TEXT NOT NULL +)`) + + return err +} + +func (r *SQLiteTokenRepository) Close() error { + return r.db.Close() +} + +func (r *SQLiteTokenRepository) Save( + token appidentity.IssuedToken, +) appidentity.IssuedToken { + tokenJSON, err := json.Marshal(token) + if err != nil { + panic(fmt.Errorf("marshal identity token: %w", err)) + } + + _, err = r.db.Exec( + `INSERT INTO identity_tokens ( + id, + token_json + ) VALUES (?, ?) + ON CONFLICT(id) DO UPDATE SET + token_json = excluded.token_json`, + token.ID, + string(tokenJSON), + ) + if err != nil { + panic(fmt.Errorf("save identity token: %w", err)) + } + + return token +} + +func (r *SQLiteTokenRepository) Get( + id string, +) (appidentity.IssuedToken, error) { + row := r.db.QueryRow(` +SELECT token_json +FROM identity_tokens +WHERE id = ?`, id) + + token, err := scanToken(row) + if errors.Is(err, sql.ErrNoRows) { + return appidentity.IssuedToken{}, appidentity.ErrTokenNotFound + } + if err != nil { + return appidentity.IssuedToken{}, err + } + + return token, nil +} + +func (r *SQLiteTokenRepository) Delete(id string) error { + result, err := r.db.Exec(`DELETE FROM identity_tokens WHERE id = ?`, id) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return appidentity.ErrTokenNotFound + } + + return nil +} + +func (r *SQLiteTokenRepository) Reset() { + if _, err := r.db.Exec(`DELETE FROM identity_tokens`); err != nil { + panic(fmt.Errorf("reset identity tokens: %w", err)) + } +} + +func scanToken(scanner rowScanner) (appidentity.IssuedToken, error) { + var tokenJSON string + if err := scanner.Scan(&tokenJSON); err != nil { + return appidentity.IssuedToken{}, err + } + + var token appidentity.IssuedToken + if err := json.Unmarshal([]byte(tokenJSON), &token); err != nil { + return appidentity.IssuedToken{}, err + } + + return token, nil +} diff --git a/internal/store/identity/sqlite_token_repository_test.go b/internal/store/identity/sqlite_token_repository_test.go new file mode 100644 index 0000000..320ff58 --- /dev/null +++ b/internal/store/identity/sqlite_token_repository_test.go @@ -0,0 +1,104 @@ +package identity_test + +import ( + "path/filepath" + "testing" + + appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" + storeidentity "github.com/JSYoo5B/SandStack/internal/store/identity" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens" + "github.com/stretchr/testify/suite" +) + +type SQLiteTokenRepositorySuite struct { + suite.Suite + repository *storeidentity.SQLiteTokenRepository +} + +func TestSQLiteTokenRepositorySuite(t *testing.T) { + suite.Run(t, new(SQLiteTokenRepositorySuite)) +} + +func (s *SQLiteTokenRepositorySuite) SetupTest() { + repository, err := storeidentity.OpenSQLiteTokenRepository(":memory:") + s.Require().NoError(err) + + s.repository = repository +} + +func (s *SQLiteTokenRepositorySuite) TearDownTest() { + s.Require().NoError(s.repository.Close()) +} + +func (s *SQLiteTokenRepositorySuite) TestSaveGetAndDeleteToken() { + created := s.repository.Save(testIssuedToken()) + + found, err := s.repository.Get(created.ID) + s.Require().NoError(err) + s.Assert().Equal(created, found) + + err = s.repository.Delete(created.ID) + s.Require().NoError(err) + + _, err = s.repository.Get(created.ID) + s.Require().ErrorIs(err, appidentity.ErrTokenNotFound) +} + +func (s *SQLiteTokenRepositorySuite) TestResetClearsTokens() { + s.repository.Save(testIssuedToken()) + + s.repository.Reset() + + _, err := s.repository.Get("token-1") + s.Require().ErrorIs(err, appidentity.ErrTokenNotFound) +} + +func (s *SQLiteTokenRepositorySuite) TestFileBackedDatabasePersistsTokens() { + path := filepath.Join(s.T().TempDir(), "sandstack.db") + repository, err := storeidentity.OpenSQLiteTokenRepository(path) + s.Require().NoError(err) + + created := repository.Save(testIssuedToken()) + s.Require().NoError(repository.Close()) + + reopened, err := storeidentity.OpenSQLiteTokenRepository(path) + s.Require().NoError(err) + defer reopened.Close() + + found, err := reopened.Get(created.ID) + s.Require().NoError(err) + + s.Assert().Equal(created, found) +} + +func testIssuedToken() appidentity.IssuedToken { + return appidentity.IssuedToken{ + ID: "token-1", + ExpiresAt: "2026-07-04T00:00:00Z", + IssuedAt: "2026-07-03T00:00:00Z", + Methods: []string{"password"}, + User: tokens.User{ + ID: "user-1", + Name: "admin", + Domain: tokens.Domain{ + ID: "default", + Name: "Default", + }, + }, + Project: tokens.Project{ + ID: "demo", + Name: "demo", + Domain: tokens.Domain{ + ID: "default", + Name: "Default", + }, + }, + Roles: []tokens.Role{ + { + ID: "admin", + Name: "admin", + }, + }, + Catalog: []tokens.CatalogEntry{}, + } +} From 7e581423cf9ff20ac21fe4a624ff30bec324cfff Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:59:54 +0900 Subject: [PATCH 11/11] feat(identity): add sqlite catalog repositories --- .../identity/sqlite_catalog_repositories.go | 341 ++++++++++++++++++ .../sqlite_catalog_repositories_test.go | 111 ++++++ 2 files changed, 452 insertions(+) create mode 100644 internal/store/identity/sqlite_catalog_repositories.go create mode 100644 internal/store/identity/sqlite_catalog_repositories_test.go diff --git a/internal/store/identity/sqlite_catalog_repositories.go b/internal/store/identity/sqlite_catalog_repositories.go new file mode 100644 index 0000000..9d715e5 --- /dev/null +++ b/internal/store/identity/sqlite_catalog_repositories.go @@ -0,0 +1,341 @@ +package identity + +import ( + "database/sql" + "errors" + "fmt" + + appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" + _ "modernc.org/sqlite" +) + +type SQLiteCatalogRepositories struct { + db *sql.DB + Services *SQLiteServiceRepository + Endpoints *SQLiteEndpointRepository +} + +func OpenSQLiteCatalogRepositories( + dataSourceName string, +) (*SQLiteCatalogRepositories, error) { + db, err := sql.Open("sqlite", dataSourceName) + if err != nil { + return nil, err + } + + repositories := &SQLiteCatalogRepositories{ + db: db, + Services: &SQLiteServiceRepository{db: db}, + Endpoints: &SQLiteEndpointRepository{db: db}, + } + if err := repositories.Init(); err != nil { + _ = db.Close() + return nil, err + } + + return repositories, nil +} + +func (r *SQLiteCatalogRepositories) Init() error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS identity_services ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + type TEXT NOT NULL, + description TEXT NOT NULL, + enabled INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS identity_endpoints ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + service_id TEXT NOT NULL, + interface TEXT NOT NULL, + region TEXT NOT NULL, + path TEXT NOT NULL, + enabled INTEGER NOT NULL, + description TEXT NOT NULL + )`, + } + + for _, statement := range statements { + if _, err := r.db.Exec(statement); err != nil { + return err + } + } + + return nil +} + +func (r *SQLiteCatalogRepositories) Close() error { + return r.db.Close() +} + +type SQLiteServiceRepository struct { + db *sql.DB +} + +func (r *SQLiteServiceRepository) Save( + service appidentity.ServiceDefinition, +) appidentity.ServiceDefinition { + _, err := r.db.Exec( + `INSERT INTO identity_services ( + id, + name, + type, + description, + enabled + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + type = excluded.type, + description = excluded.description, + enabled = excluded.enabled`, + service.ID, + service.Name, + service.Type, + service.Description, + boolToInt(service.Enabled), + ) + if err != nil { + panic(fmt.Errorf("save identity service: %w", err)) + } + + return service +} + +func (r *SQLiteServiceRepository) List() []appidentity.ServiceDefinition { + rows, err := r.db.Query(` +SELECT + id, + name, + type, + description, + enabled +FROM identity_services +ORDER BY sequence`) + if err != nil { + panic(fmt.Errorf("list identity services: %w", err)) + } + defer rows.Close() + + services := []appidentity.ServiceDefinition{} + for rows.Next() { + service, err := scanService(rows) + if err != nil { + panic(fmt.Errorf("scan identity service: %w", err)) + } + services = append(services, service) + } + if err := rows.Err(); err != nil { + panic(fmt.Errorf("iterate identity services: %w", err)) + } + + return services +} + +func (r *SQLiteServiceRepository) Get( + id string, +) (appidentity.ServiceDefinition, error) { + row := r.db.QueryRow(` +SELECT + id, + name, + type, + description, + enabled +FROM identity_services +WHERE id = ?`, id) + + service, err := scanService(row) + if errors.Is(err, sql.ErrNoRows) { + return appidentity.ServiceDefinition{}, appidentity.ErrServiceNotFound + } + if err != nil { + return appidentity.ServiceDefinition{}, err + } + + return service, nil +} + +func (r *SQLiteServiceRepository) Reset() { + if _, err := r.db.Exec(`DELETE FROM identity_services`); err != nil { + panic(fmt.Errorf("reset identity services: %w", err)) + } +} + +type SQLiteEndpointRepository struct { + db *sql.DB +} + +func (r *SQLiteEndpointRepository) Save( + endpoint appidentity.EndpointDefinition, +) appidentity.EndpointDefinition { + _, err := r.db.Exec( + `INSERT INTO identity_endpoints ( + id, + service_id, + interface, + region, + path, + enabled, + description + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + service_id = excluded.service_id, + interface = excluded.interface, + region = excluded.region, + path = excluded.path, + enabled = excluded.enabled, + description = excluded.description`, + endpoint.ID, + endpoint.ServiceID, + endpoint.Interface, + endpoint.Region, + endpoint.Path, + boolToInt(endpoint.Enabled), + endpoint.Description, + ) + if err != nil { + panic(fmt.Errorf("save identity endpoint: %w", err)) + } + + return endpoint +} + +func (r *SQLiteEndpointRepository) List() []appidentity.EndpointDefinition { + rows, err := r.db.Query(` +SELECT + id, + service_id, + interface, + region, + path, + enabled, + description +FROM identity_endpoints +ORDER BY sequence`) + if err != nil { + panic(fmt.Errorf("list identity endpoints: %w", err)) + } + defer rows.Close() + + endpoints := []appidentity.EndpointDefinition{} + for rows.Next() { + endpoint, err := scanEndpoint(rows) + if err != nil { + panic(fmt.Errorf("scan identity endpoint: %w", err)) + } + endpoints = append(endpoints, endpoint) + } + if err := rows.Err(); err != nil { + panic(fmt.Errorf("iterate identity endpoints: %w", err)) + } + + return endpoints +} + +func (r *SQLiteEndpointRepository) Get( + id string, +) (appidentity.EndpointDefinition, error) { + row := r.db.QueryRow(` +SELECT + id, + service_id, + interface, + region, + path, + enabled, + description +FROM identity_endpoints +WHERE id = ?`, id) + + endpoint, err := scanEndpoint(row) + if errors.Is(err, sql.ErrNoRows) { + return appidentity.EndpointDefinition{}, appidentity.ErrEndpointNotFound + } + if err != nil { + return appidentity.EndpointDefinition{}, err + } + + return endpoint, nil +} + +func (r *SQLiteEndpointRepository) ListByServiceID( + serviceID string, +) []appidentity.EndpointDefinition { + rows, err := r.db.Query(` +SELECT + id, + service_id, + interface, + region, + path, + enabled, + description +FROM identity_endpoints +WHERE service_id = ? +ORDER BY sequence`, serviceID) + if err != nil { + panic(fmt.Errorf("list identity endpoints by service: %w", err)) + } + defer rows.Close() + + endpoints := []appidentity.EndpointDefinition{} + for rows.Next() { + endpoint, err := scanEndpoint(rows) + if err != nil { + panic(fmt.Errorf("scan identity endpoint: %w", err)) + } + endpoints = append(endpoints, endpoint) + } + if err := rows.Err(); err != nil { + panic(fmt.Errorf("iterate identity endpoints: %w", err)) + } + + return endpoints +} + +func (r *SQLiteEndpointRepository) Reset() { + if _, err := r.db.Exec(`DELETE FROM identity_endpoints`); err != nil { + panic(fmt.Errorf("reset identity endpoints: %w", err)) + } +} + +func scanService(scanner rowScanner) (appidentity.ServiceDefinition, error) { + var service appidentity.ServiceDefinition + var enabled int + err := scanner.Scan( + &service.ID, + &service.Name, + &service.Type, + &service.Description, + &enabled, + ) + if err != nil { + return appidentity.ServiceDefinition{}, err + } + service.Enabled = intToBool(enabled) + + return service, nil +} + +func scanEndpoint(scanner rowScanner) (appidentity.EndpointDefinition, error) { + var endpoint appidentity.EndpointDefinition + var enabled int + err := scanner.Scan( + &endpoint.ID, + &endpoint.ServiceID, + &endpoint.Interface, + &endpoint.Region, + &endpoint.Path, + &enabled, + &endpoint.Description, + ) + if err != nil { + return appidentity.EndpointDefinition{}, err + } + endpoint.Enabled = intToBool(enabled) + + return endpoint, nil +} diff --git a/internal/store/identity/sqlite_catalog_repositories_test.go b/internal/store/identity/sqlite_catalog_repositories_test.go new file mode 100644 index 0000000..67d1b8b --- /dev/null +++ b/internal/store/identity/sqlite_catalog_repositories_test.go @@ -0,0 +1,111 @@ +package identity_test + +import ( + "path/filepath" + "testing" + + appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" + storeidentity "github.com/JSYoo5B/SandStack/internal/store/identity" + "github.com/stretchr/testify/suite" +) + +type SQLiteCatalogRepositoriesSuite struct { + suite.Suite + repositories *storeidentity.SQLiteCatalogRepositories +} + +func TestSQLiteCatalogRepositoriesSuite(t *testing.T) { + suite.Run(t, new(SQLiteCatalogRepositoriesSuite)) +} + +func (s *SQLiteCatalogRepositoriesSuite) SetupTest() { + repositories, err := storeidentity.OpenSQLiteCatalogRepositories(":memory:") + s.Require().NoError(err) + + s.repositories = repositories +} + +func (s *SQLiteCatalogRepositoriesSuite) TearDownTest() { + s.Require().NoError(s.repositories.Close()) +} + +func (s *SQLiteCatalogRepositoriesSuite) TestCreateListAndGetService() { + created := s.repositories.Services.Save(testServiceDefinition()) + + listed := s.repositories.Services.List() + found, err := s.repositories.Services.Get(created.ID) + s.Require().NoError(err) + + s.Assert().Len(listed, 1) + s.Assert().Equal(created, listed[0]) + s.Assert().Equal(created, found) +} + +func (s *SQLiteCatalogRepositoriesSuite) TestCreateListAndGetEndpoint() { + created := s.repositories.Endpoints.Save(testEndpointDefinition()) + + listed := s.repositories.Endpoints.List() + byService := s.repositories.Endpoints.ListByServiceID("identity") + found, err := s.repositories.Endpoints.Get(created.ID) + s.Require().NoError(err) + + s.Assert().Len(listed, 1) + s.Assert().Equal(created, listed[0]) + s.Assert().Equal([]appidentity.EndpointDefinition{created}, byService) + s.Assert().Equal(created, found) +} + +func (s *SQLiteCatalogRepositoriesSuite) TestResetClearsCatalog() { + s.repositories.Services.Save(testServiceDefinition()) + s.repositories.Endpoints.Save(testEndpointDefinition()) + + s.repositories.Endpoints.Reset() + s.repositories.Services.Reset() + + s.Assert().Empty(s.repositories.Endpoints.List()) + s.Assert().Empty(s.repositories.Services.List()) +} + +func (s *SQLiteCatalogRepositoriesSuite) TestFileBackedDatabasePersistsCatalog() { + path := filepath.Join(s.T().TempDir(), "sandstack.db") + repositories, err := storeidentity.OpenSQLiteCatalogRepositories(path) + s.Require().NoError(err) + + service := repositories.Services.Save(testServiceDefinition()) + endpoint := repositories.Endpoints.Save(testEndpointDefinition()) + s.Require().NoError(repositories.Close()) + + reopened, err := storeidentity.OpenSQLiteCatalogRepositories(path) + s.Require().NoError(err) + defer reopened.Close() + + foundService, err := reopened.Services.Get(service.ID) + s.Require().NoError(err) + foundEndpoint, err := reopened.Endpoints.Get(endpoint.ID) + s.Require().NoError(err) + + s.Assert().Equal(service, foundService) + s.Assert().Equal(endpoint, foundEndpoint) +} + +func testServiceDefinition() appidentity.ServiceDefinition { + return appidentity.ServiceDefinition{ + ID: "identity", + Name: "identity", + Type: "identity", + Description: "SandStack identity service", + Enabled: true, + } +} + +func testEndpointDefinition() appidentity.EndpointDefinition { + return appidentity.EndpointDefinition{ + ID: "identity-public", + ServiceID: "identity", + Interface: "public", + Region: "RegionOne", + Path: "/identity/v3", + Enabled: true, + Description: "SandStack identity endpoint", + } +}