Add OPEN_LEARNER role without LMS sequential gating.
Migration 000061 inserts the RBAC role and demo user (openlearner@yimaru.com). STUDENT keeps sequential ApplyAccess and practice ordering; OPEN_LEARNER shares learner permissions and customer flows. Document the role in Swagger and point initial seed SQL at the migration for the demo account. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
83db13bed0
commit
7e61e34292
|
|
@ -4,6 +4,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|||
-- ======================================================
|
||||
-- Customer/Learner Users (login via /api/v1/auth/customer-login)
|
||||
-- Credentials: email + password@123
|
||||
-- OPEN_LEARNER demo user is seeded by migration 000061_open_learner_role (not here).
|
||||
-- ======================================================
|
||||
INSERT INTO users (
|
||||
id,
|
||||
|
|
|
|||
5
db/migrations/000061_open_learner_role.down.sql
Normal file
5
db/migrations/000061_open_learner_role.down.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
DELETE FROM users WHERE id = 13 AND email = 'openlearner@yimaru.com';
|
||||
|
||||
DELETE FROM role_permissions WHERE role_id = (SELECT id FROM roles WHERE name = 'OPEN_LEARNER');
|
||||
|
||||
DELETE FROM roles WHERE name = 'OPEN_LEARNER';
|
||||
79
db/migrations/000061_open_learner_role.up.sql
Normal file
79
db/migrations/000061_open_learner_role.up.sql
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
-- OPEN_LEARNER: learner role with STUDENT-like RBAC but without LMS sequential prerequisite locks (handled in app code).
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
INSERT INTO roles (name, description, is_system) VALUES
|
||||
(
|
||||
'OPEN_LEARNER',
|
||||
'Learner with full LMS catalog access without sequential prerequisite locking',
|
||||
TRUE
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Demo OPEN_LEARNER (customer-login): openlearner@yimaru.com / password@123
|
||||
INSERT INTO users (
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
gender,
|
||||
birth_day,
|
||||
email,
|
||||
phone_number,
|
||||
role,
|
||||
password,
|
||||
age_group,
|
||||
education_level,
|
||||
country,
|
||||
region,
|
||||
knowledge_level,
|
||||
nick_name,
|
||||
occupation,
|
||||
learning_goal,
|
||||
language_goal,
|
||||
language_challange,
|
||||
favourite_topic,
|
||||
initial_assessment_completed,
|
||||
email_verified,
|
||||
phone_verified,
|
||||
status,
|
||||
last_login,
|
||||
profile_completed,
|
||||
profile_picture_url,
|
||||
preferred_language,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
13,
|
||||
'Demo',
|
||||
'OpenLearner',
|
||||
'Female',
|
||||
'1999-06-01',
|
||||
'openlearner@yimaru.com',
|
||||
NULL,
|
||||
'OPEN_LEARNER',
|
||||
crypt('password@123', gen_salt('bf'))::bytea,
|
||||
'25_34',
|
||||
'Bachelor',
|
||||
'Ethiopia',
|
||||
'Addis Ababa',
|
||||
'BEGINNER',
|
||||
'OpenLearner',
|
||||
'Tester',
|
||||
'Preview LMS content without sequential locks',
|
||||
'English',
|
||||
'Grammar',
|
||||
'Technology',
|
||||
FALSE,
|
||||
TRUE,
|
||||
FALSE,
|
||||
'ACTIVE',
|
||||
NULL,
|
||||
FALSE,
|
||||
NULL,
|
||||
'en',
|
||||
CURRENT_TIMESTAMP,
|
||||
NULL
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
|
@ -11063,6 +11063,7 @@ const docTemplate = `{
|
|||
"SUPER_ADMIN",
|
||||
"ADMIN",
|
||||
"STUDENT",
|
||||
"OPEN_LEARNER",
|
||||
"INSTRUCTOR",
|
||||
"SUPPORT"
|
||||
],
|
||||
|
|
@ -11070,6 +11071,7 @@ const docTemplate = `{
|
|||
"RoleSuperAdmin",
|
||||
"RoleAdmin",
|
||||
"RoleStudent",
|
||||
"RoleOpenLearner",
|
||||
"RoleInstructor",
|
||||
"RoleSupport"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -11055,6 +11055,7 @@
|
|||
"SUPER_ADMIN",
|
||||
"ADMIN",
|
||||
"STUDENT",
|
||||
"OPEN_LEARNER",
|
||||
"INSTRUCTOR",
|
||||
"SUPPORT"
|
||||
],
|
||||
|
|
@ -11062,6 +11063,7 @@
|
|||
"RoleSuperAdmin",
|
||||
"RoleAdmin",
|
||||
"RoleStudent",
|
||||
"RoleOpenLearner",
|
||||
"RoleInstructor",
|
||||
"RoleSupport"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -813,6 +813,7 @@ definitions:
|
|||
- SUPER_ADMIN
|
||||
- ADMIN
|
||||
- STUDENT
|
||||
- OPEN_LEARNER
|
||||
- INSTRUCTOR
|
||||
- SUPPORT
|
||||
type: string
|
||||
|
|
@ -820,6 +821,7 @@ definitions:
|
|||
- RoleSuperAdmin
|
||||
- RoleAdmin
|
||||
- RoleStudent
|
||||
- RoleOpenLearner
|
||||
- RoleInstructor
|
||||
- RoleSupport
|
||||
domain.RoleRecord:
|
||||
|
|
|
|||
|
|
@ -137,6 +137,8 @@ func ReceiverFromRole(role Role) NotificationRecieverSide {
|
|||
return NotificationRecieverSideAdmin
|
||||
case RoleStudent:
|
||||
return NotificationRecieverSideCustomer
|
||||
case RoleOpenLearner:
|
||||
return NotificationRecieverSideCustomer
|
||||
case RoleInstructor:
|
||||
return NotificationRecieverSideCustomer
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -6,19 +6,31 @@ const (
|
|||
RoleSuperAdmin Role = "SUPER_ADMIN"
|
||||
RoleAdmin Role = "ADMIN"
|
||||
RoleStudent Role = "STUDENT"
|
||||
// RoleOpenLearner can consume LMS content like a learner but without sequential prerequisite locking (step-by-step gates).
|
||||
RoleOpenLearner Role = "OPEN_LEARNER"
|
||||
RoleInstructor Role = "INSTRUCTOR"
|
||||
RoleSupport Role = "SUPPORT"
|
||||
)
|
||||
|
||||
func (r Role) IsValid() bool {
|
||||
switch r {
|
||||
case RoleSuperAdmin, RoleAdmin, RoleStudent, RoleInstructor, RoleSupport:
|
||||
case RoleSuperAdmin, RoleAdmin, RoleStudent, RoleOpenLearner, RoleInstructor, RoleSupport:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UsesLMSSequentialGating is true when LMS APIs apply sequential prerequisite locks (403 when blocked).
|
||||
func (r Role) UsesLMSSequentialGating() bool {
|
||||
return r == RoleStudent
|
||||
}
|
||||
|
||||
// IsCustomerLearnerRole is true for platform roles that sign in as customers and consume learner-facing LMS APIs.
|
||||
func (r Role) IsCustomerLearnerRole() bool {
|
||||
return r == RoleStudent || r == RoleOpenLearner
|
||||
}
|
||||
|
||||
func (r Role) Value() string {
|
||||
return string(r)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ func (s *Service) CanAccessLesson(ctx context.Context, userID, lessonID int64) (
|
|||
|
||||
// ApplyAccessProgram sets p.Access for a learner. Non-learners: clears Access to omit from JSON.
|
||||
func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, userID int64, p *domain.Program) error {
|
||||
if role != domain.RoleStudent {
|
||||
if !role.UsesLMSSequentialGating() {
|
||||
p.Access = nil
|
||||
return nil
|
||||
}
|
||||
|
|
@ -172,7 +172,7 @@ func (s *Service) ApplyAccessProgram(ctx context.Context, role domain.Role, user
|
|||
|
||||
// ApplyAccessCourse sets c.Access for a learner.
|
||||
func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userID int64, c *domain.Course) error {
|
||||
if role != domain.RoleStudent {
|
||||
if !role.UsesLMSSequentialGating() {
|
||||
c.Access = nil
|
||||
return nil
|
||||
}
|
||||
|
|
@ -198,7 +198,7 @@ func (s *Service) ApplyAccessCourse(ctx context.Context, role domain.Role, userI
|
|||
|
||||
// ApplyAccessModule sets m.Access for a learner.
|
||||
func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userID int64, m *domain.Module) error {
|
||||
if role != domain.RoleStudent {
|
||||
if !role.UsesLMSSequentialGating() {
|
||||
m.Access = nil
|
||||
return nil
|
||||
}
|
||||
|
|
@ -224,7 +224,7 @@ func (s *Service) ApplyAccessModule(ctx context.Context, role domain.Role, userI
|
|||
|
||||
// ApplyAccessLesson sets l.Access for a learner.
|
||||
func (s *Service) ApplyAccessLesson(ctx context.Context, role domain.Role, userID int64, les *domain.Lesson) error {
|
||||
if role != domain.RoleStudent {
|
||||
if !role.UsesLMSSequentialGating() {
|
||||
les.Access = nil
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -302,10 +302,71 @@ var AllPermissions = []domain.PermissionSeed{
|
|||
{Key: "internal.db.reset_reseed", Name: "Reset And Reseed Database", Description: "Dangerous operation: clears all data and re-seeds from SQL files", GroupName: "Internal Operations"},
|
||||
}
|
||||
|
||||
// defaultStudentLearnerPermissions is the learner consumption permission set shared by STUDENT and OPEN_LEARNER.
|
||||
// LMS sequential prerequisite locking applies only to STUDENT in application handlers.
|
||||
var defaultStudentLearnerPermissions = []string{
|
||||
// Course browsing
|
||||
"course_categories.list", "course_categories.get",
|
||||
"courses.get", "courses.list_by_program",
|
||||
"modules.get", "modules.list_by_course",
|
||||
"lessons.get", "lessons.list_by_module", "lessons.complete",
|
||||
"practices.get", "practices.list",
|
||||
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
|
||||
"videos.get", "videos.list_by_subcourse", "videos.list_published",
|
||||
"learning_tree.get",
|
||||
|
||||
"programs.list", "programs.get",
|
||||
"exam_prep.catalog_courses.list", "exam_prep.catalog_courses.get",
|
||||
"exam_prep.units.list", "exam_prep.units.get",
|
||||
"exam_prep.modules.list", "exam_prep.modules.get",
|
||||
"exam_prep.lessons.list_by_module", "exam_prep.lessons.get",
|
||||
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
|
||||
"lms.get_my_progress",
|
||||
|
||||
// Questions (read + attempt)
|
||||
"questions.list", "questions.search", "questions.get",
|
||||
"question_sets.list", "question_sets.list_by_owner", "question_sets.get",
|
||||
"question_set_items.list",
|
||||
"question_set_personas.list",
|
||||
|
||||
// Subscriptions & Payments (own)
|
||||
"subscriptions.checkout", "subscriptions.get_mine", "subscriptions.history",
|
||||
"subscriptions.status", "subscriptions.cancel", "subscriptions.set_auto_renew",
|
||||
"payments.initiate", "payments.verify", "payments.list_mine", "payments.get", "payments.cancel",
|
||||
"payments.direct_initiate", "payments.direct_verify_otp",
|
||||
|
||||
// User (self-service)
|
||||
"users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile",
|
||||
|
||||
// Notifications (own)
|
||||
"notifications.ws_connect", "notifications.list_mine", "notifications.list_all",
|
||||
"notifications.mark_read", "notifications.mark_all_read", "notifications.mark_unread", "notifications.mark_all_unread",
|
||||
"notifications.delete_mine", "notifications.count_unread",
|
||||
"notifications.test_push",
|
||||
|
||||
// Issues (own)
|
||||
"issues.create", "issues.list_mine",
|
||||
|
||||
// Devices
|
||||
"devices.register", "devices.unregister",
|
||||
|
||||
// Progress
|
||||
"progress.start", "progress.update", "progress.complete", "progress.check_access", "progress.get_course",
|
||||
|
||||
// Sub-course Prerequisites (read)
|
||||
"subcourse_prerequisites.list",
|
||||
|
||||
// Ratings
|
||||
"ratings.submit", "ratings.list_by_target", "ratings.summary", "ratings.get_mine", "ratings.list_mine", "ratings.delete",
|
||||
|
||||
// Auth
|
||||
"auth.logout",
|
||||
}
|
||||
|
||||
// DefaultRolePermissions maps each system role to the permission keys it should
|
||||
// have by default. This preserves the previous middleware behavior:
|
||||
// - ADMIN: everything that was previously OnlyAdminAndAbove + SuperAdminOnly + all authenticated routes
|
||||
// - STUDENT/INSTRUCTOR/SUPPORT: only self-service endpoints (profile, courses, progress, etc.)
|
||||
// - STUDENT/OPEN_LEARNER/INSTRUCTOR/SUPPORT: only self-service endpoints (profile, courses, progress, etc.)
|
||||
var DefaultRolePermissions = map[string][]string{
|
||||
"ADMIN": {
|
||||
// Course Management (full access)
|
||||
|
|
@ -409,64 +470,9 @@ var DefaultRolePermissions = map[string][]string{
|
|||
"internal.db.reset_reseed",
|
||||
},
|
||||
|
||||
"STUDENT": {
|
||||
// Course browsing
|
||||
"course_categories.list", "course_categories.get",
|
||||
"courses.get", "courses.list_by_program",
|
||||
"modules.get", "modules.list_by_course",
|
||||
"lessons.get", "lessons.list_by_module", "lessons.complete",
|
||||
"practices.get", "practices.list",
|
||||
"subcourses.get", "subcourses.list_by_course", "subcourses.list_by_course_list", "subcourses.list_active",
|
||||
"videos.get", "videos.list_by_subcourse", "videos.list_published",
|
||||
"learning_tree.get",
|
||||
"STUDENT": defaultStudentLearnerPermissions,
|
||||
|
||||
"programs.list", "programs.get",
|
||||
"exam_prep.catalog_courses.list", "exam_prep.catalog_courses.get",
|
||||
"exam_prep.units.list", "exam_prep.units.get",
|
||||
"exam_prep.modules.list", "exam_prep.modules.get",
|
||||
"exam_prep.lessons.list_by_module", "exam_prep.lessons.get",
|
||||
"exam_prep.practices.list_by_lesson", "exam_prep.practices.get",
|
||||
"lms.get_my_progress",
|
||||
|
||||
// Questions (read + attempt)
|
||||
"questions.list", "questions.search", "questions.get",
|
||||
"question_sets.list", "question_sets.list_by_owner", "question_sets.get",
|
||||
"question_set_items.list",
|
||||
"question_set_personas.list",
|
||||
|
||||
// Subscriptions & Payments (own)
|
||||
"subscriptions.checkout", "subscriptions.get_mine", "subscriptions.history",
|
||||
"subscriptions.status", "subscriptions.cancel", "subscriptions.set_auto_renew",
|
||||
"payments.initiate", "payments.verify", "payments.list_mine", "payments.get", "payments.cancel",
|
||||
"payments.direct_initiate", "payments.direct_verify_otp",
|
||||
|
||||
// User (self-service)
|
||||
"users.update_self", "users.delete_self", "users.cancel_delete_self", "users.profile_completed", "users.upload_profile_picture", "users.user_profile",
|
||||
|
||||
// Notifications (own)
|
||||
"notifications.ws_connect", "notifications.list_mine", "notifications.list_all",
|
||||
"notifications.mark_read", "notifications.mark_all_read", "notifications.mark_unread", "notifications.mark_all_unread",
|
||||
"notifications.delete_mine", "notifications.count_unread",
|
||||
"notifications.test_push",
|
||||
|
||||
// Issues (own)
|
||||
"issues.create", "issues.list_mine",
|
||||
|
||||
// Devices
|
||||
"devices.register", "devices.unregister",
|
||||
|
||||
// Progress
|
||||
"progress.start", "progress.update", "progress.complete", "progress.check_access", "progress.get_course",
|
||||
|
||||
// Sub-course Prerequisites (read)
|
||||
"subcourse_prerequisites.list",
|
||||
|
||||
// Ratings
|
||||
"ratings.submit", "ratings.list_by_target", "ratings.summary", "ratings.get_mine", "ratings.list_mine", "ratings.delete",
|
||||
|
||||
// Auth
|
||||
"auth.logout",
|
||||
},
|
||||
"OPEN_LEARNER": defaultStudentLearnerPermissions,
|
||||
|
||||
"INSTRUCTOR": {
|
||||
// Course browsing + management
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ func (h *Handler) LoginAdmin(c *fiber.Ctx) error {
|
|||
}
|
||||
}
|
||||
|
||||
if successRes.Role == domain.RoleStudent || successRes.Role == domain.RoleInstructor {
|
||||
if successRes.Role == domain.RoleStudent || successRes.Role == domain.RoleOpenLearner || successRes.Role == domain.RoleInstructor {
|
||||
h.mongoLoggerSvc.Warn("Login attempt: admin login of user",
|
||||
zap.Int("status_code", fiber.StatusForbidden),
|
||||
zap.String("role", string(successRes.Role)),
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ func (h *Handler) CompleteLesson(c *fiber.Ctx) error {
|
|||
}
|
||||
uid := c.Locals("user_id").(int64)
|
||||
role := c.Locals("role").(domain.Role)
|
||||
if role == domain.RoleStudent {
|
||||
if role.UsesLMSSequentialGating() {
|
||||
ok, reason, err := h.lmsProgressSvc.CanAccessLesson(c.Context(), uid, id)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
|
|
|
|||
|
|
@ -737,7 +737,7 @@ func isSequenceGatedPractice(set domain.QuestionSet) bool {
|
|||
|
||||
func (h *Handler) enforcePracticeSequenceForStudent(c *fiber.Ctx, set domain.QuestionSet) error {
|
||||
role := c.Locals("role").(domain.Role)
|
||||
if role != domain.RoleStudent || !isSequenceGatedPractice(set) {
|
||||
if !role.UsesLMSSequentialGating() || !isSequenceGatedPractice(set) {
|
||||
return nil
|
||||
}
|
||||
if !strings.EqualFold(set.Status, "PUBLISHED") {
|
||||
|
|
@ -1547,7 +1547,7 @@ func (h *Handler) GetQuestionsByPractice(c *fiber.Ctx) error {
|
|||
// @Router /api/v1/progress/practices/{id}/complete [post]
|
||||
func (h *Handler) CompletePractice(c *fiber.Ctx) error {
|
||||
role := c.Locals("role").(domain.Role)
|
||||
if role != domain.RoleStudent {
|
||||
if !role.IsCustomerLearnerRole() {
|
||||
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
||||
Message: "Only learners can complete practices",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1933,7 +1933,7 @@ func (h *Handler) DeleteMyUserAccount(c *fiber.Ctx) error {
|
|||
if !ok {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Invalid authenticated role")
|
||||
}
|
||||
if role != domain.RoleStudent {
|
||||
if !role.IsCustomerLearnerRole() {
|
||||
return fiber.NewError(fiber.StatusForbidden, "Only learners can delete their own account using this endpoint")
|
||||
}
|
||||
|
||||
|
|
@ -1986,7 +1986,7 @@ func (h *Handler) CancelMyUserAccountDeletion(c *fiber.Ctx) error {
|
|||
if !ok {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Invalid authenticated role")
|
||||
}
|
||||
if role != domain.RoleStudent {
|
||||
if !role.IsCustomerLearnerRole() {
|
||||
return fiber.NewError(fiber.StatusForbidden, "Only learners can cancel their own account deletion using this endpoint")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ func (a *App) RequireActiveSubscription() fiber.Handler {
|
|||
switch role {
|
||||
case domain.RoleSuperAdmin, domain.RoleAdmin, domain.RoleInstructor, domain.RoleSupport:
|
||||
return c.Next()
|
||||
case domain.RoleStudent:
|
||||
case domain.RoleStudent, domain.RoleOpenLearner:
|
||||
userID, ok := c.Locals("user_id").(int64)
|
||||
if !ok || userID == 0 {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Unauthorized")
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user